Merge remote-tracking branch 'origin/main' into main
# Conflicts: # gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java # gymboard-api/src/main/resources/application-development.properties # gymboard-app/src/layouts/MainLayout.vue
This commit is contained in:
commit
595a33a801
|
@ -33,3 +33,6 @@ build/
|
|||
|
||||
.sample_data
|
||||
exercise_submission_temp_files/
|
||||
private_key.der
|
||||
private_key.pem
|
||||
public_key.der
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env rdmd
|
||||
/**
|
||||
* A simple script that generates the keys needed by DigiOPP Auth for signing
|
||||
* tokens and other cryptographic needs. Use this in your development
|
||||
* environment to ensure that your local auth project can issue keys to other
|
||||
* development services.
|
||||
*
|
||||
* Authors: Andrew Lalis
|
||||
*/
|
||||
module gen_keys;
|
||||
|
||||
import std.stdio;
|
||||
import std.file;
|
||||
|
||||
const privateKeyFile = "private_key.pem";
|
||||
const privateKeyDerFile = "private_key.der";
|
||||
const publicKeyDerFile = "public_key.der";
|
||||
|
||||
const cmdGenRSAPrivateKey = "openssl genrsa -out private_key.pem 2048";
|
||||
const cmdGenDERPrivateKey = "openssl pkcs8 -topk8 -inform PEM -outform DER -in private_key.pem -out private_key.der -nocrypt";
|
||||
const cmdGenDERPublicKey = "openssl rsa -in private_key.pem -pubout -outform DER -out public_key.der";
|
||||
|
||||
void removeIfExists(string[] files...) {
|
||||
foreach (f; files) if (exists(f)) remove(f);
|
||||
}
|
||||
|
||||
void genKeys(string[] files...) {
|
||||
import std.process : executeShell;
|
||||
import std.algorithm : canFind;
|
||||
if (canFind(files, privateKeyFile)) executeShell(cmdGenRSAPrivateKey);
|
||||
if (canFind(files, privateKeyDerFile)) executeShell(cmdGenDERPrivateKey);
|
||||
if (canFind(files, publicKeyDerFile)) executeShell(cmdGenDERPublicKey);
|
||||
}
|
||||
|
||||
void main() {
|
||||
if (!exists(privateKeyFile)) {
|
||||
writeln("No RSA private key found. Regenerating all key files.");
|
||||
removeIfExists(privateKeyDerFile, publicKeyDerFile);
|
||||
genKeys(privateKeyFile, privateKeyDerFile, publicKeyDerFile);
|
||||
} else if (!exists(privateKeyDerFile)) {
|
||||
writeln("No DER private key found. Regenerating private and public DER files.");
|
||||
removeIfExists(privateKeyDerFile, publicKeyDerFile);
|
||||
genKeys(privateKeyDerFile, publicKeyDerFile);
|
||||
} else if (!exists(publicKeyDerFile)) {
|
||||
writeln("No DER public key found. Regenerating it.");
|
||||
genKeys(publicKeyDerFile);
|
||||
}
|
||||
writeln("All keys are now generated.");
|
||||
}
|
|
@ -21,6 +21,10 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
|
@ -29,6 +33,10 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
|
@ -41,6 +49,25 @@
|
|||
<version>1.9.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT dependencies -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.2</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
|
||||
<version>0.11.2</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Test dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
jay.cutler@example.com,testpass,Jay Cutler,
|
||||
mike.mentzer@example.com,testpass,Mike Mentzer,
|
||||
ronnie.coleman@example.com,testpass,Ronnie 'Lightweight' Coleman,
|
||||
andrew.lalis@example.com,testpass,Andrew Lalis,admin
|
|
|
@ -0,0 +1,85 @@
|
|||
package nl.andrewlalis.gymboard_api.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
private final TokenAuthenticationFilter tokenAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(TokenAuthenticationFilter tokenAuthenticationFilter) {
|
||||
this.tokenAuthenticationFilter = tokenAuthenticationFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the security configuration we'll use for this API.
|
||||
* @param http The security configurable.
|
||||
* @return The filter chain to apply.
|
||||
* @throws Exception If an error occurs while configuring.
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.httpBasic().disable()
|
||||
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
|
||||
.csrf().disable()
|
||||
.cors().and()
|
||||
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.authorizeHttpRequests()
|
||||
.requestMatchers(// Allow the following GET endpoints to be public.
|
||||
HttpMethod.GET,
|
||||
"/exercises",
|
||||
"/leaderboards",
|
||||
"/gyms/**",
|
||||
"/submissions/**"
|
||||
).permitAll()
|
||||
.requestMatchers(// Allow the following POST endpoints to be public.
|
||||
HttpMethod.POST,
|
||||
"/gyms/submissions",
|
||||
"/gyms/submissions/upload",
|
||||
"/auth/token"
|
||||
).permitAll()
|
||||
// Everything else must be authenticated, just to be safe.
|
||||
.anyRequest().authenticated();
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the CORS configuration for this API, which is to say that we
|
||||
* allow cross-origin requests ONLY from the web app for the vast majority
|
||||
* of endpoints.
|
||||
* @return The CORS configuration source.
|
||||
*/
|
||||
@Bean
|
||||
@Order(1)
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
final CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowCredentials(true);
|
||||
// Don't do this in production, use a proper list of allowed origins
|
||||
config.addAllowedOriginPattern("*");
|
||||
config.addAllowedHeader("*");
|
||||
config.addAllowedMethod("*");
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager() {
|
||||
return null;// Disable the standard spring authentication manager.
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package nl.andrewlalis.gymboard_api.config;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jws;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.TokenAuthentication;
|
||||
import nl.andrewlalis.gymboard_api.service.auth.TokenService;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final TokenService tokenService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public TokenAuthenticationFilter(TokenService tokenService, UserRepository userRepository) {
|
||||
this.tokenService = tokenService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
String token = tokenService.extractBearerToken(request);
|
||||
Jws<Claims> jws = tokenService.getToken(token);
|
||||
if (jws != null) {
|
||||
userRepository.findByIdWithRoles(jws.getBody().getSubject())
|
||||
.ifPresent(user -> SecurityContextHolder.getContext().setAuthentication(new TokenAuthentication(user, token)));
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package nl.andrewlalis.gymboard_api.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Configuration
|
||||
public class WebComponents {
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(10);
|
||||
}
|
||||
}
|
|
@ -1,15 +1,8 @@
|
|||
package nl.andrewlalis.gymboard_api.config;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.util.ULID;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Arrays;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
package nl.andrewlalis.gymboard_api.controller;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.UserResponse;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.User;
|
||||
import nl.andrewlalis.gymboard_api.service.auth.TokenService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class AuthController {
|
||||
private final TokenService tokenService;
|
||||
|
||||
public AuthController(TokenService tokenService) {
|
||||
this.tokenService = tokenService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint for obtaining a new access token for a user to access certain
|
||||
* parts of the application. <strong>This is a public endpoint.</strong> If
|
||||
* a token is successfully obtained, it should be provided to all subsequent
|
||||
* requests as an Authorization "Bearer" token.
|
||||
* @param credentials The credentials.
|
||||
* @return The token the client should use.
|
||||
*/
|
||||
@PostMapping(path = "/auth/token")
|
||||
public TokenResponse getToken(@RequestBody TokenCredentials credentials) {
|
||||
return tokenService.generateAccessToken(credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint that can be used by an authenticated user to fetch a new access
|
||||
* token using their current one; useful for staying logged in beyond the
|
||||
* duration of the initial token's expiration.
|
||||
* @param auth The current authentication.
|
||||
* @return The new token the client should use.
|
||||
*/
|
||||
@GetMapping(path = "/auth/token")
|
||||
public TokenResponse getUpdatedToken(Authentication auth) {
|
||||
return tokenService.regenerateAccessToken(auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets information about the user, as determined by the provided access
|
||||
* token.
|
||||
* @param user The user that requested information.
|
||||
* @return The user data.
|
||||
*/
|
||||
@GetMapping(path = "/auth/me")
|
||||
public UserResponse getMyUser(@AuthenticationPrincipal User user) {
|
||||
return new UserResponse(user);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ package nl.andrewlalis.gymboard_api.controller;
|
|||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.*;
|
||||
import nl.andrewlalis.gymboard_api.service.GymService;
|
||||
import nl.andrewlalis.gymboard_api.service.LeaderboardService;
|
||||
import nl.andrewlalis.gymboard_api.service.UploadService;
|
||||
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
|
||||
import org.springframework.http.MediaType;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
public record TokenCredentials(
|
||||
String email,
|
||||
String password
|
||||
) {}
|
|
@ -0,0 +1,3 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
public record TokenResponse(String token) {}
|
|
@ -0,0 +1,7 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
public record UserCreationPayload(
|
||||
String email,
|
||||
String password,
|
||||
String name
|
||||
) {}
|
|
@ -0,0 +1,19 @@
|
|||
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.model.auth.User;
|
||||
|
||||
public record UserResponse(
|
||||
String id,
|
||||
boolean activated,
|
||||
String email,
|
||||
String name
|
||||
) {
|
||||
public UserResponse(User user) {
|
||||
this(
|
||||
user.getId(),
|
||||
user.isActivated(),
|
||||
user.getEmail(),
|
||||
user.getName()
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package nl.andrewlalis.gymboard_api.dao.auth;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.model.auth.Role;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface RoleRepository extends JpaRepository<Role, String> {
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package nl.andrewlalis.gymboard_api.dao.auth;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.model.auth.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, String>, JpaSpecificationExecutor<User> {
|
||||
boolean existsByEmail(String email);
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.id = :id")
|
||||
Optional<User> findByIdWithRoles(String id);
|
||||
|
||||
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
|
||||
Optional<User> findByEmailWithRoles(String email);
|
||||
}
|
|
@ -2,14 +2,20 @@ package nl.andrewlalis.gymboard_api.model;
|
|||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload;
|
||||
import nl.andrewlalis.gymboard_api.dao.CityRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.CountryRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.auth.RoleRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
|
||||
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.Role;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.User;
|
||||
import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
|
||||
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
|
||||
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
|
||||
import nl.andrewlalis.gymboard_api.service.UploadService;
|
||||
import nl.andrewlalis.gymboard_api.service.auth.UserService;
|
||||
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.slf4j.Logger;
|
||||
|
@ -39,19 +45,27 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
private final ExerciseRepository exerciseRepository;
|
||||
private final ExerciseSubmissionService submissionService;
|
||||
private final UploadService uploadService;
|
||||
private final RoleRepository roleRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
|
||||
public SampleDataLoader(
|
||||
CountryRepository countryRepository,
|
||||
CityRepository cityRepository,
|
||||
GymRepository gymRepository,
|
||||
ExerciseRepository exerciseRepository,
|
||||
ExerciseSubmissionService submissionService, UploadService uploadService) {
|
||||
ExerciseSubmissionService submissionService,
|
||||
UploadService uploadService,
|
||||
RoleRepository roleRepository, UserRepository userRepository, UserService userService) {
|
||||
this.countryRepository = countryRepository;
|
||||
this.cityRepository = cityRepository;
|
||||
this.gymRepository = gymRepository;
|
||||
this.exerciseRepository = exerciseRepository;
|
||||
this.submissionService = submissionService;
|
||||
this.uploadService = uploadService;
|
||||
this.roleRepository = roleRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -122,6 +136,24 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
|||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
loadCsv("users", record -> {
|
||||
String email = record.get(0);
|
||||
String password = record.get(1);
|
||||
String name = record.get(2);
|
||||
String[] roleNames = record.get(3).split("\\s*\\|\\s*");
|
||||
|
||||
UserCreationPayload payload = new UserCreationPayload(email, password, name);
|
||||
var resp = userService.createUser(payload);
|
||||
User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow();
|
||||
for (var roleName : roleNames) {
|
||||
if (roleName.isBlank()) continue;
|
||||
Role role = roleRepository.findById(roleName.strip().toLowerCase())
|
||||
.orElseGet(() -> roleRepository.save(new Role(roleName.strip().toLowerCase())));
|
||||
user.getRoles().add(role);
|
||||
}
|
||||
userRepository.save(user);
|
||||
});
|
||||
}
|
||||
|
||||
private void loadCsv(String csvName, Consumer<CSVRecord> recordConsumer) throws IOException {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package nl.andrewlalis.gymboard_api.model.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_role")
|
||||
public class Role {
|
||||
@Id
|
||||
@Column(nullable = false)
|
||||
private String shortName;
|
||||
|
||||
public Role() {}
|
||||
|
||||
public Role(String shortName) {
|
||||
this.shortName = shortName;
|
||||
}
|
||||
|
||||
public String getShortName() {
|
||||
return shortName;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package nl.andrewlalis.gymboard_api.model.auth;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
||||
public record TokenAuthentication(
|
||||
User user,
|
||||
String token
|
||||
) implements Authentication {
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getDetails() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
|
||||
// Not allowed.
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return user.getEmail();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package nl.andrewlalis.gymboard_api.model.auth;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_user")
|
||||
public class User {
|
||||
@Id
|
||||
@Column(nullable = false, updatable = false, length = 26)
|
||||
private String id;
|
||||
|
||||
@CreationTimestamp
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean activated;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "auth_user_roles",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "role_short_name")
|
||||
)
|
||||
private Set<Role> roles;
|
||||
|
||||
public User() {}
|
||||
|
||||
public User(String id, boolean activated, String email, String passwordHash, String name) {
|
||||
this.id = id;
|
||||
this.activated = activated;
|
||||
this.email = email;
|
||||
this.passwordHash = passwordHash;
|
||||
this.name = name;
|
||||
this.roles = new HashSet<>();
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public boolean isActivated() {
|
||||
return activated;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Set<Role> getRoles() {
|
||||
return roles;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
package nl.andrewlalis.gymboard_api.service.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jws;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse;
|
||||
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.Role;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.TokenAuthentication;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.User;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TokenService {
|
||||
private static final Logger log = LoggerFactory.getLogger(TokenService.class);
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
private static final String ISSUER = "Gymboard";
|
||||
private PrivateKey privateKey = null;
|
||||
|
||||
@Value("${app.auth.private-key-location}")
|
||||
private String privateKeyLocation;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public TokenService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
public String generateAccessToken(User user) {
|
||||
Instant expiration = Instant.now().plus(30, ChronoUnit.MINUTES);
|
||||
return Jwts.builder()
|
||||
.setSubject(user.getId())
|
||||
.setIssuer(ISSUER)
|
||||
.setAudience("Gymboard App")
|
||||
.setExpiration(Date.from(expiration))
|
||||
.claim("email", user.getEmail())
|
||||
.claim("name", user.getName())
|
||||
.claim("roles", user.getRoles().stream()
|
||||
.map(Role::getShortName)
|
||||
.collect(Collectors.joining(",")))
|
||||
.signWith(getPrivateKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public TokenResponse generateAccessToken(TokenCredentials credentials) {
|
||||
User user = userRepository.findByEmailWithRoles(credentials.email())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||
if (!passwordEncoder.matches(credentials.password(), user.getPasswordHash())) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if (!user.isActivated()) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
String token = generateAccessToken(user);
|
||||
return new TokenResponse(token);
|
||||
}
|
||||
|
||||
public TokenResponse regenerateAccessToken(Authentication auth) {
|
||||
if (!(auth instanceof TokenAuthentication tokenAuth)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
User user = userRepository.findByIdWithRoles(tokenAuth.user().getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||
if (!user.isActivated()) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
String token = generateAccessToken(user);
|
||||
return new TokenResponse(token);
|
||||
}
|
||||
|
||||
public String extractBearerToken(HttpServletRequest request) {
|
||||
String authorizationHeader = request.getHeader("Authorization");
|
||||
if (authorizationHeader == null || authorizationHeader.isBlank()) return null;
|
||||
if (authorizationHeader.startsWith(BEARER_PREFIX)) {
|
||||
return authorizationHeader.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Jws<Claims> getToken(String token) {
|
||||
if (token == null) return null;
|
||||
var builder = Jwts.parserBuilder()
|
||||
.setSigningKey(this.getPrivateKey())
|
||||
.requireIssuer(ISSUER);
|
||||
return builder.build().parseClaimsJws(token);
|
||||
}
|
||||
|
||||
private PrivateKey getPrivateKey() {
|
||||
if (privateKey == null) {
|
||||
try {
|
||||
byte[] keyBytes = Files.readAllBytes(Path.of(this.privateKeyLocation));
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||
KeyFactory kf = KeyFactory.getInstance("RSA");
|
||||
privateKey = kf.generatePrivate(spec);
|
||||
} catch (Exception e) {
|
||||
log.error("Could not obtain private key.", e);
|
||||
throw new RuntimeException("Cannot obtain private key.", e);
|
||||
}
|
||||
}
|
||||
return privateKey;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package nl.andrewlalis.gymboard_api.service.auth;
|
||||
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload;
|
||||
import nl.andrewlalis.gymboard_api.controller.dto.UserResponse;
|
||||
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
|
||||
import nl.andrewlalis.gymboard_api.model.auth.User;
|
||||
import nl.andrewlalis.gymboard_api.util.ULID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
private final ULID ulid;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public UserService(UserRepository userRepository, ULID ulid, PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.ulid = ulid;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse getUser(String id) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
return new UserResponse(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse createUser(UserCreationPayload payload) {
|
||||
// TODO: Validate user payload.
|
||||
User user = userRepository.save(new User(
|
||||
ulid.nextULID(),
|
||||
true, // TODO: Change this to false once email activation is in.
|
||||
payload.email(),
|
||||
passwordEncoder.encode(payload.password()),
|
||||
payload.name()
|
||||
));
|
||||
return new UserResponse(user);
|
||||
}
|
||||
}
|
|
@ -90,7 +90,7 @@ public class SubmissionProcessingService {
|
|||
|
||||
// Set the status to processing.
|
||||
submission.setStatus(ExerciseSubmission.Status.PROCESSING);
|
||||
exerciseSubmissionRepository.save(submission);
|
||||
exerciseSubmissionRepository.saveAndFlush(submission);
|
||||
|
||||
// Then try and fetch the temporary video file associated with it.
|
||||
Optional<ExerciseSubmissionTempFile> optionalTempFile = tempFileRepository.findBySubmission(submission);
|
||||
|
@ -105,7 +105,7 @@ public class SubmissionProcessingService {
|
|||
if (!Files.exists(tempFilePath) || !Files.isReadable(tempFilePath)) {
|
||||
log.error("Submission {} failed because the temporary video file {} isn't readable.", submission.getId(), tempFilePath);
|
||||
submission.setStatus(ExerciseSubmission.Status.FAILED);
|
||||
exerciseSubmissionRepository.save(submission);
|
||||
exerciseSubmissionRepository.saveAndFlush(submission);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -135,7 +135,7 @@ public class SubmissionProcessingService {
|
|||
e.getMessage()
|
||||
);
|
||||
submission.setStatus(ExerciseSubmission.Status.FAILED);
|
||||
exerciseSubmissionRepository.save(submission);
|
||||
exerciseSubmissionRepository.saveAndFlush(submission);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -9,4 +9,5 @@ spring.jpa.show-sql=false
|
|||
spring.task.execution.pool.core-size=3
|
||||
spring.task.execution.pool.max-size=10
|
||||
|
||||
app.auth.private-key-location=./private_key.der
|
||||
app.web-origin=http://localhost:9000
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { api } from 'src/api/main/index';
|
||||
import { AuthStoreType } from 'stores/auth-store';
|
||||
import Timeout = NodeJS.Timeout;
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
activated: boolean;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TokenCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
class AuthModule {
|
||||
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
|
||||
|
||||
private tokenRefreshTimer?: Timeout;
|
||||
|
||||
public async login(authStore: AuthStoreType, credentials: TokenCredentials) {
|
||||
authStore.token = await this.fetchNewToken(credentials);
|
||||
authStore.user = await this.fetchMyUser(authStore);
|
||||
|
||||
clearTimeout(this.tokenRefreshTimer);
|
||||
this.tokenRefreshTimer = setTimeout(
|
||||
() => this.refreshToken(authStore),
|
||||
AuthModule.TOKEN_REFRESH_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
|
||||
public logout(authStore: AuthStoreType) {
|
||||
authStore.$reset();
|
||||
clearTimeout(this.tokenRefreshTimer);
|
||||
}
|
||||
|
||||
private async fetchNewToken(credentials: TokenCredentials): Promise<string> {
|
||||
const response = await api.post('/auth/token', credentials);
|
||||
return response.data.token;
|
||||
}
|
||||
|
||||
private async refreshToken(authStore: AuthStoreType) {
|
||||
const response = await api.get('/auth/token', authStore.axiosConfig);
|
||||
authStore.token = response.data.token;
|
||||
}
|
||||
|
||||
private async fetchMyUser(authStore: AuthStoreType): Promise<User> {
|
||||
const response = await api.get('/auth/me', authStore.axiosConfig);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthModule;
|
|
@ -49,7 +49,9 @@ class GymsModule {
|
|||
};
|
||||
}
|
||||
|
||||
public async getRecentSubmissions(gym: GymRoutable): Promise<Array<ExerciseSubmission>> {
|
||||
public async getRecentSubmissions(
|
||||
gym: GymRoutable
|
||||
): Promise<Array<ExerciseSubmission>> {
|
||||
const response = await api.get(
|
||||
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/recent-submissions`
|
||||
);
|
||||
|
|
|
@ -2,6 +2,7 @@ import axios from 'axios';
|
|||
import GymsModule from 'src/api/main/gyms';
|
||||
import ExercisesModule from 'src/api/main/exercises';
|
||||
import LeaderboardsModule from 'src/api/main/leaderboards';
|
||||
import AuthModule from 'src/api/main/auth';
|
||||
|
||||
export const BASE_URL = 'http://localhost:8080';
|
||||
|
||||
|
@ -11,6 +12,7 @@ export const api = axios.create({
|
|||
});
|
||||
|
||||
class GymboardApi {
|
||||
public readonly auth = new AuthModule();
|
||||
public readonly gyms = new GymsModule();
|
||||
public readonly exercises = new ExercisesModule();
|
||||
public readonly leaderboards = new LeaderboardsModule();
|
||||
|
|
|
@ -3,11 +3,11 @@ import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
|
|||
import { api } from 'src/api/main/index';
|
||||
|
||||
export enum LeaderboardTimeframe {
|
||||
DAY = "DAY",
|
||||
WEEK = "WEEK",
|
||||
MONTH = "MONTH",
|
||||
YEAR = "YEAR",
|
||||
ALL = "ALL"
|
||||
DAY = 'DAY',
|
||||
WEEK = 'WEEK',
|
||||
MONTH = 'MONTH',
|
||||
YEAR = 'YEAR',
|
||||
ALL = 'ALL',
|
||||
}
|
||||
|
||||
export interface LeaderboardParams {
|
||||
|
@ -27,14 +27,16 @@ interface RequestParams {
|
|||
}
|
||||
|
||||
class LeaderboardsModule {
|
||||
public async getLeaderboard(params: LeaderboardParams): Promise<Array<ExerciseSubmission>> {
|
||||
public async getLeaderboard(
|
||||
params: LeaderboardParams
|
||||
): Promise<Array<ExerciseSubmission>> {
|
||||
const requestParams: RequestParams = {};
|
||||
if (params.exerciseShortName) {
|
||||
requestParams.exercise = params.exerciseShortName;
|
||||
}
|
||||
if (params.gyms) {
|
||||
requestParams.gyms = params.gyms
|
||||
.map(gym => getGymCompoundId(gym))
|
||||
.map((gym) => getGymCompoundId(gym))
|
||||
.join(',');
|
||||
}
|
||||
if (params.timeframe) {
|
||||
|
|
|
@ -38,10 +38,10 @@ export enum ExerciseSubmissionStatus {
|
|||
}
|
||||
|
||||
class SubmissionsModule {
|
||||
public async getSubmission(submissionId: string): Promise<ExerciseSubmission> {
|
||||
const response = await api.get(
|
||||
`/submissions/${submissionId}`
|
||||
);
|
||||
public async getSubmission(
|
||||
submissionId: string
|
||||
): Promise<ExerciseSubmission> {
|
||||
const response = await api.get(`/submissions/${submissionId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
@ -52,7 +52,7 @@ class SubmissionsModule {
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
return BASE_URL + `/submissions/${submission.id}/video`
|
||||
return BASE_URL + `/submissions/${submission.id}/video`;
|
||||
}
|
||||
|
||||
public async createSubmission(
|
||||
|
@ -60,10 +60,7 @@ class SubmissionsModule {
|
|||
payload: ExerciseSubmissionPayload
|
||||
): Promise<ExerciseSubmission> {
|
||||
const gymId = getGymCompoundId(gym);
|
||||
const response = await api.post(
|
||||
`/gyms/${gymId}/submissions`,
|
||||
payload
|
||||
);
|
||||
const response = await api.post(`/gyms/${gymId}/submissions`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
@ -85,7 +82,9 @@ class SubmissionsModule {
|
|||
* Asynchronous method that waits until a submission is done processing.
|
||||
* @param submissionId The submission's id.
|
||||
*/
|
||||
public async waitUntilSubmissionProcessed(submissionId: string): Promise<ExerciseSubmission> {
|
||||
public async waitUntilSubmissionProcessed(
|
||||
submissionId: string
|
||||
): Promise<ExerciseSubmission> {
|
||||
let failureCount = 0;
|
||||
let attemptCount = 0;
|
||||
while (failureCount < 5 && attemptCount < 60) {
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<div class="q-mx-sm">
|
||||
<q-btn-dropdown
|
||||
color="primary"
|
||||
:label="authStore.user?.name"
|
||||
v-if="authStore.loggedIn"
|
||||
no-caps
|
||||
icon="person"
|
||||
>
|
||||
<q-list>
|
||||
<q-item clickable v-close-popup @click="api.auth.logout(authStore)">
|
||||
<q-item-section>
|
||||
<q-item-label>Log out</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<q-btn
|
||||
color="primary"
|
||||
:label="$t('Login')"
|
||||
v-if="!authStore.loggedIn"
|
||||
no-caps
|
||||
icon="person"
|
||||
to="/login"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from 'stores/auth-store';
|
||||
import api from 'src/api/main';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,7 +1,15 @@
|
|||
<template>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
:label="submission.rawWeight + ' ' + submission.weightUnit + ' x' + submission.reps + ' ' + submission.exercise.displayName"
|
||||
:label="
|
||||
submission.rawWeight +
|
||||
' ' +
|
||||
submission.weightUnit +
|
||||
' x' +
|
||||
submission.reps +
|
||||
' ' +
|
||||
submission.exercise.displayName
|
||||
"
|
||||
:caption="submission.submitterName"
|
||||
>
|
||||
<q-card>
|
||||
|
@ -24,7 +32,7 @@ import {ExerciseSubmission} from 'src/api/main/submission';
|
|||
import api from 'src/api/main';
|
||||
|
||||
interface Props {
|
||||
submission: ExerciseSubmission
|
||||
submission: ExerciseSubmission;
|
||||
}
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<q-select
|
||||
v-model="i18n.locale.value"
|
||||
:options="localeOptions"
|
||||
:label="$t('mainLayout.language')"
|
||||
dense
|
||||
borderless
|
||||
emit-value
|
||||
map-options
|
||||
options-dense
|
||||
filled
|
||||
hide-bottom-space
|
||||
dark
|
||||
options-dark
|
||||
label-color="white"
|
||||
options-selected-class="text-grey"
|
||||
style="min-width: 150px"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const i18n = useI18n({ useScope: 'global' });
|
||||
const localeOptions = [
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'nl-NL', label: 'Nederlands' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -12,7 +12,7 @@ export default {
|
|||
leaderboard: 'Leaderboard',
|
||||
homePage: {
|
||||
overview: 'Overview of this gym:',
|
||||
recentLifts: 'Recent Lifts'
|
||||
recentLifts: 'Recent Lifts',
|
||||
},
|
||||
submitPage: {
|
||||
name: 'Your Name',
|
||||
|
|
|
@ -12,7 +12,7 @@ export default {
|
|||
leaderboard: 'Scorebord',
|
||||
homePage: {
|
||||
overview: 'Overzicht van dit sportschool:',
|
||||
recentLifts: 'Recente liften'
|
||||
recentLifts: 'Recente liften',
|
||||
},
|
||||
submitPage: {
|
||||
name: 'Jouw naam',
|
||||
|
|
|
@ -16,23 +16,8 @@
|
|||
>Gymboard</router-link
|
||||
>
|
||||
</q-toolbar-title>
|
||||
<q-select
|
||||
v-model="i18n.locale.value"
|
||||
:options="localeOptions"
|
||||
:label="$t('mainLayout.language')"
|
||||
dense
|
||||
borderless
|
||||
emit-value
|
||||
map-options
|
||||
options-dense
|
||||
filled
|
||||
hide-bottom-space
|
||||
dark
|
||||
options-dark
|
||||
label-color="white"
|
||||
options-selected-class="text-grey"
|
||||
style="min-width: 150px"
|
||||
/>
|
||||
<AccountMenuItem />
|
||||
<LocaleSelect />
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
|
@ -43,6 +28,7 @@
|
|||
</q-item-label>
|
||||
<q-item clickable to="/">Gyms</q-item>
|
||||
<q-item clickable>Global Leaderboard</q-item>
|
||||
<q-item clickable to="/testing">Testing Page</q-item>
|
||||
<q-item clickable to="/about">About</q-item>
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
|
@ -55,14 +41,8 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const i18n = useI18n({ useScope: 'global' });
|
||||
const localeOptions = [
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'nl-NL', label: 'Nederlands' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
];
|
||||
import LocaleSelect from 'components/LocaleSelect.vue';
|
||||
import AccountMenuItem from 'components/AccountMenuItem.vue';
|
||||
|
||||
const leftDrawerOpen = ref(false);
|
||||
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<StandardCenteredPage>
|
||||
<h3>Testing Page</h3>
|
||||
<p>
|
||||
Use this page to test new functionality, before adding it to the main app.
|
||||
This page should be hidden on production.
|
||||
</p>
|
||||
<div style="border: 3px solid red">
|
||||
<h4>Auth Test</h4>
|
||||
<q-btn label="Do auth" @click="doAuth()" />
|
||||
<q-btn label="Logout" @click="api.auth.logout(authStore)" />
|
||||
</div>
|
||||
</StandardCenteredPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
|
||||
import api from 'src/api/main';
|
||||
import { useAuthStore } from 'stores/auth-store';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
async function doAuth() {
|
||||
await api.auth.login(authStore, {
|
||||
email: 'andrew.lalis@example.com',
|
||||
password: 'testpass',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -4,11 +4,22 @@
|
|||
<div class="col-xs-12 col-md-6 q-pt-md">
|
||||
<p>{{ $t('gymPage.homePage.overview') }}</p>
|
||||
<ul>
|
||||
<li v-if="gym.websiteUrl">Website: <a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a></li>
|
||||
<li>Address: <em>{{ gym.streetAddress }}</em></li>
|
||||
<li>City: <em>{{ gym.cityName }}</em></li>
|
||||
<li>Country: <em>{{ gym.countryName }}</em></li>
|
||||
<li>Registered at: <em>{{ gym.createdAt }}</em></li>
|
||||
<li v-if="gym.websiteUrl">
|
||||
Website:
|
||||
<a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a>
|
||||
</li>
|
||||
<li>
|
||||
Address: <em>{{ gym.streetAddress }}</em>
|
||||
</li>
|
||||
<li>
|
||||
City: <em>{{ gym.cityName }}</em>
|
||||
</li>
|
||||
<li>
|
||||
Country: <em>{{ gym.countryName }}</em>
|
||||
</li>
|
||||
<li>
|
||||
Registered at: <em>{{ gym.createdAt }}</em>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6">
|
||||
|
@ -19,7 +30,11 @@
|
|||
<div v-if="recentSubmissions.length > 0">
|
||||
<h4 class="text-center">{{ $t('gymPage.homePage.recentLifts') }}</h4>
|
||||
<q-list>
|
||||
<ExerciseSubmissionListItem v-for="sub in recentSubmissions" :submission="sub" :key="sub.id"/>
|
||||
<ExerciseSubmissionListItem
|
||||
v-for="sub in recentSubmissions"
|
||||
:submission="sub"
|
||||
:key="sub.id"
|
||||
/>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-page>
|
||||
|
@ -39,7 +54,8 @@ const recentSubmissions: Ref<Array<ExerciseSubmission>> = ref([]);
|
|||
const gym: Ref<Gym | undefined> = ref();
|
||||
|
||||
const TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OSM</a>';
|
||||
const ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a>';
|
||||
const map: Ref<Map | undefined> = ref();
|
||||
const mapContainer = ref();
|
||||
|
||||
|
@ -55,13 +71,18 @@ function initMap() {
|
|||
const g: Gym = gym.value;
|
||||
console.log(mapContainer);
|
||||
|
||||
const tiles = new TileLayer(TILE_URL, { attribution: ATTRIBUTION, maxZoom: 19 });
|
||||
const tiles = new TileLayer(TILE_URL, {
|
||||
attribution: ATTRIBUTION,
|
||||
maxZoom: 19,
|
||||
});
|
||||
const marker = new Marker([g.location.latitude, g.location.longitude], {
|
||||
title: g.displayName,
|
||||
alt: g.displayName
|
||||
alt: g.displayName,
|
||||
});
|
||||
map.value = new Map(mapContainer.value, {})
|
||||
.setView([g.location.latitude, g.location.longitude], 16);
|
||||
map.value = new Map(mapContainer.value, {}).setView(
|
||||
[g.location.latitude, g.location.longitude],
|
||||
16
|
||||
);
|
||||
|
||||
tiles.addTo(map.value);
|
||||
marker.addTo(map.value);
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
<template>
|
||||
<q-page>
|
||||
<div class="q-ma-md row justify-end q-gutter-sm">
|
||||
<q-spinner
|
||||
color="primary"
|
||||
size="3em"
|
||||
v-if="loadingIndicatorActive"
|
||||
/>
|
||||
<q-spinner color="primary" size="3em" v-if="loadingIndicatorActive" />
|
||||
<q-select
|
||||
v-model="selectedExercise"
|
||||
:options="exerciseOptions"
|
||||
|
@ -22,7 +18,11 @@
|
|||
/>
|
||||
</div>
|
||||
<q-list>
|
||||
<ExerciseSubmissionListItem v-for="sub in submissions" :submission="sub" :key="sub.id"/>
|
||||
<ExerciseSubmissionListItem
|
||||
v-for="sub in submissions"
|
||||
:submission="sub"
|
||||
:key="sub.id"
|
||||
/>
|
||||
</q-list>
|
||||
</q-page>
|
||||
</template>
|
||||
|
@ -43,10 +43,10 @@ const gym: Ref<Gym | undefined> = ref();
|
|||
const exercises: Ref<Array<Exercise>> = ref([]);
|
||||
|
||||
const exerciseOptions = computed(() => {
|
||||
let options = exercises.value.map(exercise => {
|
||||
let options = exercises.value.map((exercise) => {
|
||||
return {
|
||||
value: exercise.shortName,
|
||||
label: exercise.displayName
|
||||
label: exercise.displayName,
|
||||
};
|
||||
});
|
||||
options.push({ value: '', label: 'Any' });
|
||||
|
@ -61,7 +61,9 @@ const timeframeOptions = [
|
|||
{ value: LeaderboardTimeframe.YEAR, label: 'Year' },
|
||||
{ value: LeaderboardTimeframe.ALL, label: 'All' },
|
||||
];
|
||||
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(LeaderboardTimeframe.DAY);
|
||||
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(
|
||||
LeaderboardTimeframe.DAY
|
||||
);
|
||||
|
||||
const loadingIndicatorActive = ref(false);
|
||||
|
||||
|
@ -79,7 +81,7 @@ async function doSearch() {
|
|||
submissions.value = await api.leaderboards.getLeaderboard({
|
||||
timeframe: selectedTimeframe.value,
|
||||
gyms: [gym.value],
|
||||
exerciseShortName: selectedExercise.value
|
||||
exerciseShortName: selectedExercise.value,
|
||||
});
|
||||
loadingIndicatorActive.value = false;
|
||||
}
|
||||
|
|
|
@ -144,7 +144,9 @@ onMounted(async () => {
|
|||
});
|
||||
|
||||
function submitButtonEnabled() {
|
||||
return selectedVideoFile.value !== undefined && !submitting.value && validateForm();
|
||||
return (
|
||||
selectedVideoFile.value !== undefined && !submitting.value && validateForm()
|
||||
);
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
|
@ -176,7 +178,6 @@ async function onSubmitted() {
|
|||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import GymPage from 'pages/gym/GymPage.vue';
|
|||
import GymSubmissionPage from 'pages/gym/GymSubmissionPage.vue';
|
||||
import GymHomePage from 'pages/gym/GymHomePage.vue';
|
||||
import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue';
|
||||
import TestingPage from 'pages/TestingPage.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
|
@ -13,6 +14,7 @@ const routes: RouteRecordRaw[] = [
|
|||
component: MainLayout,
|
||||
children: [
|
||||
{ path: '', component: IndexPage },
|
||||
{ path: 'testing', component: TestingPage },
|
||||
{
|
||||
path: 'gyms/:countryCode/:cityShortName/:gymShortName',
|
||||
component: GymPage,
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* This store keeps track of the authentication state of the web app, which
|
||||
* is just keeping the current user and their token.
|
||||
*
|
||||
* See src/api/main/auth.ts for mutators of this store.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { User } from 'src/api/main/auth';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('authStore', {
|
||||
state: (): AuthState => {
|
||||
return { user: null, token: null };
|
||||
},
|
||||
getters: {
|
||||
loggedIn: (state) => state.user !== null && state.token !== null,
|
||||
axiosConfig(state) {
|
||||
if (this.token !== null) {
|
||||
return {
|
||||
headers: { Authorization: 'Bearer ' + state.token },
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type AuthStoreType = ReturnType<typeof useAuthStore>;
|
Loading…
Reference in New Issue