diff --git a/README.md b/README.md index c1216e9..f9af1ca 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # Gymboard Leaderboards for your local community gym. + +## Development +Gymboard is comprised of a variety of components, each in its own directory, and with its own project format. Follow the instructions in the README of the respective project to set that one up. + +A `docker-compose.yml` file is defined in this directory, and it defines a set of services that may be used by one or more services. Install docker on your system if you haven't already, and run `docker-compose up -d` to start the services. diff --git a/docker-compose.yml b/docker-compose.yml index 5175a26..ffe7fe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,3 +12,22 @@ services: environment: POSTGRES_USER: gymboard-api-dev POSTGRES_PASSWORD: testpass + + # Database for the gymboard-cdn. + cdn-db: + image: postgres + restart: always + ports: + - "5433:5432" + environment: + POSTGRES_USER: gymboard-cdn-dev + POSTGRES_PASSWORD: testpass + + mailhog: + image: mailhog/mailhog + restart: always + expose: + - "8025" + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI diff --git a/gymboard-api/pom.xml b/gymboard-api/pom.xml index 60f0eb6..43d5ff0 100644 --- a/gymboard-api/pom.xml +++ b/gymboard-api/pom.xml @@ -37,6 +37,10 @@ org.springframework.boot spring-boot-starter-websocket + + org.springframework.boot + spring-boot-starter-mail + org.postgresql diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java index 2e79713..9697285 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -49,9 +49,11 @@ public class SecurityConfig { ).permitAll() .requestMatchers(// Allow the following POST endpoints to be public. HttpMethod.POST, - "/gyms/submissions", - "/gyms/submissions/upload", - "/auth/token" + "/gyms/*/submissions", + "/gyms/*/submissions/upload", + "/auth/token", + "/auth/register", + "/auth/activate" ).permitAll() // Everything else must be authenticated, just to be safe. .anyRequest().authenticated(); diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java index da03fe3..6ecc175 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java @@ -1,10 +1,9 @@ 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.controller.dto.*; import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.service.auth.TokenService; +import nl.andrewlalis.gymboard_api.service.auth.UserService; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -15,9 +14,35 @@ import org.springframework.web.bind.annotation.RestController; @RestController public class AuthController { private final TokenService tokenService; + private final UserService userService; - public AuthController(TokenService tokenService) { + public AuthController(TokenService tokenService, UserService userService) { this.tokenService = tokenService; + this.userService = userService; + } + + /** + * Endpoint for registering a new user in the system. This is a + * public endpoint. If the user is successfully created, an email + * will be sent to them with a link for activating the account. + * @param payload The payload. + * @return The created user. + */ + @PostMapping(path = "/auth/register") + public UserResponse registerNewUser(@RequestBody UserCreationPayload payload) { + return userService.createUser(payload, true); + } + + /** + * Endpoint for activating a new user via an activation code. This + * is a public endpoint. If the code is recent (within 24 hours) + * and the user exists, then they'll be activated and able to log in. + * @param payload The payload containing the activation code. + * @return The activated user. + */ + @PostMapping(path = "/auth/activate") + public UserResponse activateUser(@RequestBody UserActivationPayload payload) { + return userService.activateUser(payload); } /** diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserActivationPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserActivationPayload.java new file mode 100644 index 0000000..5f00816 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserActivationPayload.java @@ -0,0 +1,3 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record UserActivationPayload(String code) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserActivationCodeRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserActivationCodeRepository.java new file mode 100644 index 0000000..d1191aa --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserActivationCodeRepository.java @@ -0,0 +1,12 @@ +package nl.andrewlalis.gymboard_api.dao.auth; + +import nl.andrewlalis.gymboard_api.model.auth.UserActivationCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserActivationCodeRepository extends JpaRepository { + Optional findByCode(String code); +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java index 907ef7f..9433701 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java @@ -144,7 +144,7 @@ public class SampleDataLoader implements ApplicationListenerHello %s,

+ +

+ Thank you for registering a new account at Gymboard! +

+

+ Please click here to activate your account. +

+ """, + user.getName(), + activationLink + ); + MimeMessage msg = mailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(msg, "UTF-8"); + helper.setFrom("Gymboard "); + helper.setSubject("Activate Your Gymboard Account"); + helper.setTo(user.getEmail()); + helper.setText(emailContent, true); + mailSender.send(msg); + } catch (MessagingException e) { + log.error("Error sending user activation email.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Transactional + public UserResponse activateUser(UserActivationPayload payload) { + UserActivationCode activationCode = activationCodeRepository.findByCode(payload.code()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + LocalDateTime cutoff = LocalDateTime.now().minusDays(1); + if (activationCode.getCreatedAt().isBefore(cutoff)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Code is expired."); + } + User user = activationCode.getUser(); + user.setActivated(true); + userRepository.save(user); return new UserResponse(user); } } diff --git a/gymboard-api/src/main/resources/application-development.properties b/gymboard-api/src/main/resources/application-development.properties index 3342dbd..a891663 100644 --- a/gymboard-api/src/main/resources/application-development.properties +++ b/gymboard-api/src/main/resources/application-development.properties @@ -9,5 +9,10 @@ spring.jpa.show-sql=false spring.task.execution.pool.core-size=3 spring.task.execution.pool.max-size=10 +spring.mail.host=127.0.0.1 +spring.mail.port=1025 +spring.mail.protocol=smtp +spring.mail.properties.mail.smtp.timeout=10000 + app.auth.private-key-location=./private_key.der app.web-origin=http://localhost:9000 diff --git a/gymboard-app/quasar.config.js b/gymboard-app/quasar.config.js index bd2275d..4a68346 100644 --- a/gymboard-app/quasar.config.js +++ b/gymboard-app/quasar.config.js @@ -111,7 +111,9 @@ module.exports = configure(function (ctx) { // directives: [], // Quasar plugins - plugins: [], + plugins: [ + 'Notify' + ], }, // animations: 'all', // --- includes all animations diff --git a/gymboard-app/src/api/main/auth.ts b/gymboard-app/src/api/main/auth.ts index 7f3e6c3..a51abad 100644 --- a/gymboard-app/src/api/main/auth.ts +++ b/gymboard-app/src/api/main/auth.ts @@ -14,6 +14,12 @@ export interface TokenCredentials { password: string; } +export interface UserCreationPayload { + name: string; + email: string; + password: string; +} + class AuthModule { private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000; @@ -35,6 +41,16 @@ class AuthModule { clearTimeout(this.tokenRefreshTimer); } + public async register(payload: UserCreationPayload) { + const response = await api.post('/auth/register', payload); + console.log(response); + } + + public async activateUser(code: string): Promise { + const response = await api.post('/auth/activate', {code: code}); + return response.data; + } + private async fetchNewToken(credentials: TokenCredentials): Promise { const response = await api.post('/auth/token', credentials); return response.data.token; diff --git a/gymboard-app/src/boot/i18n.ts b/gymboard-app/src/boot/i18n.ts index 6a844a1..58f6716 100644 --- a/gymboard-app/src/boot/i18n.ts +++ b/gymboard-app/src/boot/i18n.ts @@ -1,5 +1,5 @@ -import { boot } from 'quasar/wrappers'; -import { createI18n } from 'vue-i18n'; +import {boot} from 'quasar/wrappers'; +import {createI18n} from 'vue-i18n'; import messages from 'src/i18n'; @@ -28,6 +28,17 @@ export default boot(({ app }) => { messages, }); + // Set the locale to the preferred locale, if possible. + const userLocale = window.navigator.language; + if (userLocale === 'nl-NL') { + i18n.global.locale.value = userLocale; + } else { + i18n.global.locale.value = 'en-US'; + } + + // Temporary override if you want to test a particular locale. + i18n.global.locale.value = 'nl-NL'; + // Set i18n instance on app app.use(i18n); }); diff --git a/gymboard-app/src/components/AccountMenuItem.vue b/gymboard-app/src/components/AccountMenuItem.vue index 48321bd..8d7065b 100644 --- a/gymboard-app/src/components/AccountMenuItem.vue +++ b/gymboard-app/src/components/AccountMenuItem.vue @@ -10,18 +10,18 @@ - Log out + {{ $t('accountMenuItem.logOut') }} @@ -29,8 +29,20 @@ diff --git a/gymboard-app/src/components/StandardCenteredPage.vue b/gymboard-app/src/components/StandardCenteredPage.vue index 7878a7c..229f1ef 100644 --- a/gymboard-app/src/components/StandardCenteredPage.vue +++ b/gymboard-app/src/components/StandardCenteredPage.vue @@ -6,13 +6,11 @@ width for smaller screens. Use this as the root component for any pages you create. --> diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts index 4c5961f..6e72a67 100644 --- a/gymboard-app/src/i18n/en-US/index.ts +++ b/gymboard-app/src/i18n/en-US/index.ts @@ -3,6 +3,21 @@ export default { language: 'Language', pages: 'Pages', }, + registerPage: { + title: 'Create a Gymboard Account', + name: 'Name', + email: 'Email', + password: 'Password', + register: 'Register', + error: 'An error occurred.' + }, + loginPage: { + title: 'Login to Gymboard', + email: 'Email', + password: 'Password', + logIn: 'Log in', + createAccount: 'Create an account' + }, indexPage: { searchHint: 'Search for a Gym', }, @@ -24,4 +39,8 @@ export default { submit: 'Submit', }, }, + accountMenuItem: { + logIn: 'Login', + logOut: 'Log out' + } }; diff --git a/gymboard-app/src/i18n/nl-NL/index.ts b/gymboard-app/src/i18n/nl-NL/index.ts index cfccf3e..e377e05 100644 --- a/gymboard-app/src/i18n/nl-NL/index.ts +++ b/gymboard-app/src/i18n/nl-NL/index.ts @@ -3,6 +3,21 @@ export default { language: 'Taal', pages: "Pagina's", }, + registerPage: { + title: 'Maak een nieuwe Gymboard account aan', + name: 'Naam', + email: 'E-mail', + password: 'Wachtwoord', + register: 'Registreren', + error: 'Er is een fout opgetreden.' + }, + loginPage: { + title: 'Inloggen bij Gymboard', + email: 'E-mail', + password: 'Wachtwoord', + logIn: 'Inloggen', + createAccount: 'Account aanmaken' + }, indexPage: { searchHint: 'Zoek een sportschool', }, @@ -24,4 +39,8 @@ export default { submit: 'Sturen', }, }, + accountMenuItem: { + logIn: 'Inloggen', + logOut: 'Uitloggen' + } }; diff --git a/gymboard-app/src/pages/IndexPage.vue b/gymboard-app/src/pages/IndexPage.vue index c6f6f1a..62ab279 100644 --- a/gymboard-app/src/pages/IndexPage.vue +++ b/gymboard-app/src/pages/IndexPage.vue @@ -1,25 +1,27 @@ + + diff --git a/gymboard-app/src/pages/auth/LoginPage.vue b/gymboard-app/src/pages/auth/LoginPage.vue new file mode 100644 index 0000000..6e5d769 --- /dev/null +++ b/gymboard-app/src/pages/auth/LoginPage.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/gymboard-app/src/pages/auth/RegisterPage.vue b/gymboard-app/src/pages/auth/RegisterPage.vue new file mode 100644 index 0000000..2b48abe --- /dev/null +++ b/gymboard-app/src/pages/auth/RegisterPage.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue b/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue new file mode 100644 index 0000000..23a5ab0 --- /dev/null +++ b/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/gymboard-app/src/pages/gym/GymPage.vue b/gymboard-app/src/pages/gym/GymPage.vue index f8b3b69..dbb03e9 100644 --- a/gymboard-app/src/pages/gym/GymPage.vue +++ b/gymboard-app/src/pages/gym/GymPage.vue @@ -1,25 +1,27 @@