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
|
.sample_data
|
||||||
exercise_submission_temp_files/
|
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>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
@ -29,6 +33,10 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
|
@ -41,6 +49,25 @@
|
||||||
<version>1.9.0</version>
|
<version>1.9.0</version>
|
||||||
</dependency>
|
</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 -->
|
<!-- Test dependencies -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<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;
|
package nl.andrewlalis.gymboard_api.config;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.util.ULID;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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
|
@Configuration
|
||||||
public class WebConfig {
|
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.controller.dto.*;
|
||||||
import nl.andrewlalis.gymboard_api.service.GymService;
|
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.UploadService;
|
||||||
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
|
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
|
||||||
import org.springframework.http.MediaType;
|
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.CompoundGymId;
|
||||||
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
|
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.CityRepository;
|
||||||
import nl.andrewlalis.gymboard_api.dao.CountryRepository;
|
import nl.andrewlalis.gymboard_api.dao.CountryRepository;
|
||||||
import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
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.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.Exercise;
|
||||||
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
|
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.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.CSVFormat;
|
||||||
import org.apache.commons.csv.CSVRecord;
|
import org.apache.commons.csv.CSVRecord;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
@ -39,19 +45,27 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
||||||
private final ExerciseRepository exerciseRepository;
|
private final ExerciseRepository exerciseRepository;
|
||||||
private final ExerciseSubmissionService submissionService;
|
private final ExerciseSubmissionService submissionService;
|
||||||
private final UploadService uploadService;
|
private final UploadService uploadService;
|
||||||
|
private final RoleRepository roleRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
public SampleDataLoader(
|
public SampleDataLoader(
|
||||||
CountryRepository countryRepository,
|
CountryRepository countryRepository,
|
||||||
CityRepository cityRepository,
|
CityRepository cityRepository,
|
||||||
GymRepository gymRepository,
|
GymRepository gymRepository,
|
||||||
ExerciseRepository exerciseRepository,
|
ExerciseRepository exerciseRepository,
|
||||||
ExerciseSubmissionService submissionService, UploadService uploadService) {
|
ExerciseSubmissionService submissionService,
|
||||||
|
UploadService uploadService,
|
||||||
|
RoleRepository roleRepository, UserRepository userRepository, UserService userService) {
|
||||||
this.countryRepository = countryRepository;
|
this.countryRepository = countryRepository;
|
||||||
this.cityRepository = cityRepository;
|
this.cityRepository = cityRepository;
|
||||||
this.gymRepository = gymRepository;
|
this.gymRepository = gymRepository;
|
||||||
this.exerciseRepository = exerciseRepository;
|
this.exerciseRepository = exerciseRepository;
|
||||||
this.submissionService = submissionService;
|
this.submissionService = submissionService;
|
||||||
this.uploadService = uploadService;
|
this.uploadService = uploadService;
|
||||||
|
this.roleRepository = roleRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -122,6 +136,24 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
||||||
e.printStackTrace();
|
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 {
|
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.
|
// Set the status to processing.
|
||||||
submission.setStatus(ExerciseSubmission.Status.PROCESSING);
|
submission.setStatus(ExerciseSubmission.Status.PROCESSING);
|
||||||
exerciseSubmissionRepository.save(submission);
|
exerciseSubmissionRepository.saveAndFlush(submission);
|
||||||
|
|
||||||
// Then try and fetch the temporary video file associated with it.
|
// Then try and fetch the temporary video file associated with it.
|
||||||
Optional<ExerciseSubmissionTempFile> optionalTempFile = tempFileRepository.findBySubmission(submission);
|
Optional<ExerciseSubmissionTempFile> optionalTempFile = tempFileRepository.findBySubmission(submission);
|
||||||
|
@ -105,7 +105,7 @@ public class SubmissionProcessingService {
|
||||||
if (!Files.exists(tempFilePath) || !Files.isReadable(tempFilePath)) {
|
if (!Files.exists(tempFilePath) || !Files.isReadable(tempFilePath)) {
|
||||||
log.error("Submission {} failed because the temporary video file {} isn't readable.", submission.getId(), tempFilePath);
|
log.error("Submission {} failed because the temporary video file {} isn't readable.", submission.getId(), tempFilePath);
|
||||||
submission.setStatus(ExerciseSubmission.Status.FAILED);
|
submission.setStatus(ExerciseSubmission.Status.FAILED);
|
||||||
exerciseSubmissionRepository.save(submission);
|
exerciseSubmissionRepository.saveAndFlush(submission);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,7 +135,7 @@ public class SubmissionProcessingService {
|
||||||
e.getMessage()
|
e.getMessage()
|
||||||
);
|
);
|
||||||
submission.setStatus(ExerciseSubmission.Status.FAILED);
|
submission.setStatus(ExerciseSubmission.Status.FAILED);
|
||||||
exerciseSubmissionRepository.save(submission);
|
exerciseSubmissionRepository.saveAndFlush(submission);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,4 +9,5 @@ spring.jpa.show-sql=false
|
||||||
spring.task.execution.pool.core-size=3
|
spring.task.execution.pool.core-size=3
|
||||||
spring.task.execution.pool.max-size=10
|
spring.task.execution.pool.max-size=10
|
||||||
|
|
||||||
|
app.auth.private-key-location=./private_key.der
|
||||||
app.web-origin=http://localhost:9000
|
app.web-origin=http://localhost:9000
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
{}
|
{}
|
||||||
|
|
|
@ -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;
|
|
@ -1,7 +1,7 @@
|
||||||
import { GeoPoint } from 'src/api/main/models';
|
import { GeoPoint } from 'src/api/main/models';
|
||||||
import SubmissionsModule, {ExerciseSubmission} from 'src/api/main/submission';
|
import SubmissionsModule, { ExerciseSubmission } from 'src/api/main/submission';
|
||||||
import { api } from 'src/api/main/index';
|
import { api } from 'src/api/main/index';
|
||||||
import {GymRoutable} from 'src/router/gym-routing';
|
import { GymRoutable } from 'src/router/gym-routing';
|
||||||
|
|
||||||
export interface Gym {
|
export interface Gym {
|
||||||
countryCode: string;
|
countryCode: string;
|
||||||
|
@ -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(
|
const response = await api.get(
|
||||||
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/recent-submissions`
|
`/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 GymsModule from 'src/api/main/gyms';
|
||||||
import ExercisesModule from 'src/api/main/exercises';
|
import ExercisesModule from 'src/api/main/exercises';
|
||||||
import LeaderboardsModule from 'src/api/main/leaderboards';
|
import LeaderboardsModule from 'src/api/main/leaderboards';
|
||||||
|
import AuthModule from 'src/api/main/auth';
|
||||||
|
|
||||||
export const BASE_URL = 'http://localhost:8080';
|
export const BASE_URL = 'http://localhost:8080';
|
||||||
|
|
||||||
|
@ -11,6 +12,7 @@ export const api = axios.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
class GymboardApi {
|
class GymboardApi {
|
||||||
|
public readonly auth = new AuthModule();
|
||||||
public readonly gyms = new GymsModule();
|
public readonly gyms = new GymsModule();
|
||||||
public readonly exercises = new ExercisesModule();
|
public readonly exercises = new ExercisesModule();
|
||||||
public readonly leaderboards = new LeaderboardsModule();
|
public readonly leaderboards = new LeaderboardsModule();
|
||||||
|
|
|
@ -3,49 +3,51 @@ import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
|
||||||
import { api } from 'src/api/main/index';
|
import { api } from 'src/api/main/index';
|
||||||
|
|
||||||
export enum LeaderboardTimeframe {
|
export enum LeaderboardTimeframe {
|
||||||
DAY = "DAY",
|
DAY = 'DAY',
|
||||||
WEEK = "WEEK",
|
WEEK = 'WEEK',
|
||||||
MONTH = "MONTH",
|
MONTH = 'MONTH',
|
||||||
YEAR = "YEAR",
|
YEAR = 'YEAR',
|
||||||
ALL = "ALL"
|
ALL = 'ALL',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardParams {
|
export interface LeaderboardParams {
|
||||||
exerciseShortName?: string;
|
exerciseShortName?: string;
|
||||||
gyms?: Array<GymRoutable>;
|
gyms?: Array<GymRoutable>;
|
||||||
timeframe?: LeaderboardTimeframe;
|
timeframe?: LeaderboardTimeframe;
|
||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestParams {
|
interface RequestParams {
|
||||||
exercise?: string;
|
exercise?: string;
|
||||||
gyms?: string;
|
gyms?: string;
|
||||||
t?: string;
|
t?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LeaderboardsModule {
|
class LeaderboardsModule {
|
||||||
public async getLeaderboard(params: LeaderboardParams): Promise<Array<ExerciseSubmission>> {
|
public async getLeaderboard(
|
||||||
const requestParams: RequestParams = {};
|
params: LeaderboardParams
|
||||||
if (params.exerciseShortName) {
|
): Promise<Array<ExerciseSubmission>> {
|
||||||
requestParams.exercise = params.exerciseShortName;
|
const requestParams: RequestParams = {};
|
||||||
}
|
if (params.exerciseShortName) {
|
||||||
if (params.gyms) {
|
requestParams.exercise = params.exerciseShortName;
|
||||||
requestParams.gyms = params.gyms
|
|
||||||
.map(gym => getGymCompoundId(gym))
|
|
||||||
.join(',');
|
|
||||||
}
|
|
||||||
if (params.timeframe) {
|
|
||||||
requestParams.t = params.timeframe;
|
|
||||||
}
|
|
||||||
if (params.page) requestParams.page = params.page;
|
|
||||||
if (params.size) requestParams.size = params.size;
|
|
||||||
|
|
||||||
const response = await api.get('/leaderboards', { params: requestParams });
|
|
||||||
return response.data.content;
|
|
||||||
}
|
}
|
||||||
|
if (params.gyms) {
|
||||||
|
requestParams.gyms = params.gyms
|
||||||
|
.map((gym) => getGymCompoundId(gym))
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
if (params.timeframe) {
|
||||||
|
requestParams.t = params.timeframe;
|
||||||
|
}
|
||||||
|
if (params.page) requestParams.page = params.page;
|
||||||
|
if (params.size) requestParams.size = params.size;
|
||||||
|
|
||||||
|
const response = await api.get('/leaderboards', { params: requestParams });
|
||||||
|
return response.data.content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LeaderboardsModule;
|
export default LeaderboardsModule;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { SimpleGym } from 'src/api/main/gyms';
|
import { SimpleGym } from 'src/api/main/gyms';
|
||||||
import { Exercise } from 'src/api/main/exercises';
|
import { Exercise } from 'src/api/main/exercises';
|
||||||
import {api, BASE_URL} from 'src/api/main/index';
|
import { api, BASE_URL } from 'src/api/main/index';
|
||||||
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
|
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
|
||||||
import { sleep } from 'src/utils';
|
import { sleep } from 'src/utils';
|
||||||
|
|
||||||
|
@ -38,10 +38,10 @@ export enum ExerciseSubmissionStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
class SubmissionsModule {
|
class SubmissionsModule {
|
||||||
public async getSubmission(submissionId: string): Promise<ExerciseSubmission> {
|
public async getSubmission(
|
||||||
const response = await api.get(
|
submissionId: string
|
||||||
`/submissions/${submissionId}`
|
): Promise<ExerciseSubmission> {
|
||||||
);
|
const response = await api.get(`/submissions/${submissionId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ class SubmissionsModule {
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return BASE_URL + `/submissions/${submission.id}/video`
|
return BASE_URL + `/submissions/${submission.id}/video`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSubmission(
|
public async createSubmission(
|
||||||
|
@ -60,10 +60,7 @@ class SubmissionsModule {
|
||||||
payload: ExerciseSubmissionPayload
|
payload: ExerciseSubmissionPayload
|
||||||
): Promise<ExerciseSubmission> {
|
): Promise<ExerciseSubmission> {
|
||||||
const gymId = getGymCompoundId(gym);
|
const gymId = getGymCompoundId(gym);
|
||||||
const response = await api.post(
|
const response = await api.post(`/gyms/${gymId}/submissions`, payload);
|
||||||
`/gyms/${gymId}/submissions`,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,7 +82,9 @@ class SubmissionsModule {
|
||||||
* Asynchronous method that waits until a submission is done processing.
|
* Asynchronous method that waits until a submission is done processing.
|
||||||
* @param submissionId The submission's id.
|
* @param submissionId The submission's id.
|
||||||
*/
|
*/
|
||||||
public async waitUntilSubmissionProcessed(submissionId: string): Promise<ExerciseSubmission> {
|
public async waitUntilSubmissionProcessed(
|
||||||
|
submissionId: string
|
||||||
|
): Promise<ExerciseSubmission> {
|
||||||
let failureCount = 0;
|
let failureCount = 0;
|
||||||
let attemptCount = 0;
|
let attemptCount = 0;
|
||||||
while (failureCount < 5 && attemptCount < 60) {
|
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>
|
<template>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
expand-separator
|
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"
|
:caption="submission.submitterName"
|
||||||
>
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
|
@ -20,11 +28,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ExerciseSubmission} from 'src/api/main/submission';
|
import { ExerciseSubmission } from 'src/api/main/submission';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
submission: ExerciseSubmission
|
submission: ExerciseSubmission;
|
||||||
}
|
}
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
</script>
|
</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',
|
leaderboard: 'Leaderboard',
|
||||||
homePage: {
|
homePage: {
|
||||||
overview: 'Overview of this gym:',
|
overview: 'Overview of this gym:',
|
||||||
recentLifts: 'Recent Lifts'
|
recentLifts: 'Recent Lifts',
|
||||||
},
|
},
|
||||||
submitPage: {
|
submitPage: {
|
||||||
name: 'Your Name',
|
name: 'Your Name',
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default {
|
||||||
leaderboard: 'Scorebord',
|
leaderboard: 'Scorebord',
|
||||||
homePage: {
|
homePage: {
|
||||||
overview: 'Overzicht van dit sportschool:',
|
overview: 'Overzicht van dit sportschool:',
|
||||||
recentLifts: 'Recente liften'
|
recentLifts: 'Recente liften',
|
||||||
},
|
},
|
||||||
submitPage: {
|
submitPage: {
|
||||||
name: 'Jouw naam',
|
name: 'Jouw naam',
|
||||||
|
|
|
@ -16,23 +16,8 @@
|
||||||
>Gymboard</router-link
|
>Gymboard</router-link
|
||||||
>
|
>
|
||||||
</q-toolbar-title>
|
</q-toolbar-title>
|
||||||
<q-select
|
<AccountMenuItem />
|
||||||
v-model="i18n.locale.value"
|
<LocaleSelect />
|
||||||
: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"
|
|
||||||
/>
|
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
|
|
||||||
|
@ -43,6 +28,7 @@
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-item clickable to="/">Gyms</q-item>
|
<q-item clickable to="/">Gyms</q-item>
|
||||||
<q-item clickable>Global Leaderboard</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-item clickable to="/about">About</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-drawer>
|
</q-drawer>
|
||||||
|
@ -55,14 +41,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import LocaleSelect from 'components/LocaleSelect.vue';
|
||||||
|
import AccountMenuItem from 'components/AccountMenuItem.vue';
|
||||||
const i18n = useI18n({ useScope: 'global' });
|
|
||||||
const localeOptions = [
|
|
||||||
{ value: 'en-US', label: 'English' },
|
|
||||||
{ value: 'nl-NL', label: 'Nederlands' },
|
|
||||||
{ value: 'de', label: 'Deutsch' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const leftDrawerOpen = ref(false);
|
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">
|
<div class="col-xs-12 col-md-6 q-pt-md">
|
||||||
<p>{{ $t('gymPage.homePage.overview') }}</p>
|
<p>{{ $t('gymPage.homePage.overview') }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-if="gym.websiteUrl">Website: <a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a></li>
|
<li v-if="gym.websiteUrl">
|
||||||
<li>Address: <em>{{ gym.streetAddress }}</em></li>
|
Website:
|
||||||
<li>City: <em>{{ gym.cityName }}</em></li>
|
<a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a>
|
||||||
<li>Country: <em>{{ gym.countryName }}</em></li>
|
</li>
|
||||||
<li>Registered at: <em>{{ gym.createdAt }}</em></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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-6">
|
<div class="col-xs-12 col-md-6">
|
||||||
|
@ -19,27 +30,32 @@
|
||||||
<div v-if="recentSubmissions.length > 0">
|
<div v-if="recentSubmissions.length > 0">
|
||||||
<h4 class="text-center">{{ $t('gymPage.homePage.recentLifts') }}</h4>
|
<h4 class="text-center">{{ $t('gymPage.homePage.recentLifts') }}</h4>
|
||||||
<q-list>
|
<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>
|
</q-list>
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {nextTick, onMounted, ref, Ref} from 'vue';
|
import { nextTick, onMounted, ref, Ref } from 'vue';
|
||||||
import {ExerciseSubmission} from 'src/api/main/submission';
|
import { ExerciseSubmission } from 'src/api/main/submission';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
import {getGymFromRoute} from 'src/router/gym-routing';
|
import { getGymFromRoute } from 'src/router/gym-routing';
|
||||||
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
|
import ExerciseSubmissionListItem from 'components/ExerciseSubmissionListItem.vue';
|
||||||
import {Gym} from 'src/api/main/gyms';
|
import { Gym } from 'src/api/main/gyms';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import {Map, Marker, TileLayer} from 'leaflet';
|
import { Map, Marker, TileLayer } from 'leaflet';
|
||||||
|
|
||||||
const recentSubmissions: Ref<Array<ExerciseSubmission>> = ref([]);
|
const recentSubmissions: Ref<Array<ExerciseSubmission>> = ref([]);
|
||||||
const gym: Ref<Gym | undefined> = ref();
|
const gym: Ref<Gym | undefined> = ref();
|
||||||
|
|
||||||
const TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
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 map: Ref<Map | undefined> = ref();
|
||||||
const mapContainer = ref();
|
const mapContainer = ref();
|
||||||
|
|
||||||
|
@ -55,13 +71,18 @@ function initMap() {
|
||||||
const g: Gym = gym.value;
|
const g: Gym = gym.value;
|
||||||
console.log(mapContainer);
|
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], {
|
const marker = new Marker([g.location.latitude, g.location.longitude], {
|
||||||
title: g.displayName,
|
title: g.displayName,
|
||||||
alt: g.displayName
|
alt: g.displayName,
|
||||||
});
|
});
|
||||||
map.value = new Map(mapContainer.value, {})
|
map.value = new Map(mapContainer.value, {}).setView(
|
||||||
.setView([g.location.latitude, g.location.longitude], 16);
|
[g.location.latitude, g.location.longitude],
|
||||||
|
16
|
||||||
|
);
|
||||||
|
|
||||||
tiles.addTo(map.value);
|
tiles.addTo(map.value);
|
||||||
marker.addTo(map.value);
|
marker.addTo(map.value);
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page>
|
<q-page>
|
||||||
<div class="q-ma-md row justify-end q-gutter-sm">
|
<div class="q-ma-md row justify-end q-gutter-sm">
|
||||||
<q-spinner
|
<q-spinner color="primary" size="3em" v-if="loadingIndicatorActive" />
|
||||||
color="primary"
|
|
||||||
size="3em"
|
|
||||||
v-if="loadingIndicatorActive"
|
|
||||||
/>
|
|
||||||
<q-select
|
<q-select
|
||||||
v-model="selectedExercise"
|
v-model="selectedExercise"
|
||||||
:options="exerciseOptions"
|
:options="exerciseOptions"
|
||||||
|
@ -22,7 +18,11 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-list>
|
<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-list>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
@ -43,10 +43,10 @@ const gym: Ref<Gym | undefined> = ref();
|
||||||
const exercises: Ref<Array<Exercise>> = ref([]);
|
const exercises: Ref<Array<Exercise>> = ref([]);
|
||||||
|
|
||||||
const exerciseOptions = computed(() => {
|
const exerciseOptions = computed(() => {
|
||||||
let options = exercises.value.map(exercise => {
|
let options = exercises.value.map((exercise) => {
|
||||||
return {
|
return {
|
||||||
value: exercise.shortName,
|
value: exercise.shortName,
|
||||||
label: exercise.displayName
|
label: exercise.displayName,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
options.push({ value: '', label: 'Any' });
|
options.push({ value: '', label: 'Any' });
|
||||||
|
@ -61,7 +61,9 @@ const timeframeOptions = [
|
||||||
{ value: LeaderboardTimeframe.YEAR, label: 'Year' },
|
{ value: LeaderboardTimeframe.YEAR, label: 'Year' },
|
||||||
{ value: LeaderboardTimeframe.ALL, label: 'All' },
|
{ value: LeaderboardTimeframe.ALL, label: 'All' },
|
||||||
];
|
];
|
||||||
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(LeaderboardTimeframe.DAY);
|
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(
|
||||||
|
LeaderboardTimeframe.DAY
|
||||||
|
);
|
||||||
|
|
||||||
const loadingIndicatorActive = ref(false);
|
const loadingIndicatorActive = ref(false);
|
||||||
|
|
||||||
|
@ -79,7 +81,7 @@ async function doSearch() {
|
||||||
submissions.value = await api.leaderboards.getLeaderboard({
|
submissions.value = await api.leaderboards.getLeaderboard({
|
||||||
timeframe: selectedTimeframe.value,
|
timeframe: selectedTimeframe.value,
|
||||||
gyms: [gym.value],
|
gyms: [gym.value],
|
||||||
exerciseShortName: selectedExercise.value
|
exerciseShortName: selectedExercise.value,
|
||||||
});
|
});
|
||||||
loadingIndicatorActive.value = false;
|
loadingIndicatorActive.value = false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,12 +93,12 @@ A high-level overview of the submission process is as follows:
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, Ref } from 'vue';
|
import { onMounted, ref, Ref } from 'vue';
|
||||||
import {getGymFromRoute, getGymRoute} from 'src/router/gym-routing';
|
import { getGymFromRoute, getGymRoute } from 'src/router/gym-routing';
|
||||||
import SlimForm from 'components/SlimForm.vue';
|
import SlimForm from 'components/SlimForm.vue';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
import { Gym } from 'src/api/main/gyms';
|
import { Gym } from 'src/api/main/gyms';
|
||||||
import { Exercise } from 'src/api/main/exercises';
|
import { Exercise } from 'src/api/main/exercises';
|
||||||
import {useRouter} from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { sleep } from 'src/utils';
|
import { sleep } from 'src/utils';
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
|
@ -144,7 +144,9 @@ onMounted(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function submitButtonEnabled() {
|
function submitButtonEnabled() {
|
||||||
return selectedVideoFile.value !== undefined && !submitting.value && validateForm();
|
return (
|
||||||
|
selectedVideoFile.value !== undefined && !submitting.value && validateForm()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateForm() {
|
function validateForm() {
|
||||||
|
@ -176,7 +178,6 @@ async function onSubmitted() {
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -38,4 +38,4 @@ export async function getGymFromRoute(): Promise<Gym> {
|
||||||
*/
|
*/
|
||||||
export function getGymCompoundId(gym: GymRoutable): string {
|
export function getGymCompoundId(gym: GymRoutable): string {
|
||||||
return `${gym.countryCode}_${gym.cityShortName}_${gym.shortName}`;
|
return `${gym.countryCode}_${gym.cityShortName}_${gym.shortName}`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import GymPage from 'pages/gym/GymPage.vue';
|
||||||
import GymSubmissionPage from 'pages/gym/GymSubmissionPage.vue';
|
import GymSubmissionPage from 'pages/gym/GymSubmissionPage.vue';
|
||||||
import GymHomePage from 'pages/gym/GymHomePage.vue';
|
import GymHomePage from 'pages/gym/GymHomePage.vue';
|
||||||
import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue';
|
import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue';
|
||||||
|
import TestingPage from 'pages/TestingPage.vue';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
@ -13,6 +14,7 @@ const routes: RouteRecordRaw[] = [
|
||||||
component: MainLayout,
|
component: MainLayout,
|
||||||
children: [
|
children: [
|
||||||
{ path: '', component: IndexPage },
|
{ path: '', component: IndexPage },
|
||||||
|
{ path: 'testing', component: TestingPage },
|
||||||
{
|
{
|
||||||
path: 'gyms/:countryCode/:cityShortName/:gymShortName',
|
path: 'gyms/:countryCode/:cityShortName/:gymShortName',
|
||||||
component: GymPage,
|
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