diff --git a/docker-compose.yml b/docker-compose.yml
index 5175a26..9cb3126 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,3 +12,12 @@ services:
environment:
POSTGRES_USER: gymboard-api-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..2f25e8e 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
@@ -51,7 +51,9 @@ public class SecurityConfig {
HttpMethod.POST,
"/gyms/submissions",
"/gyms/submissions/upload",
- "/auth/token"
+ "/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 974c949..ffb41c6 100644
--- a/gymboard-api/src/main/resources/application-development.properties
+++ b/gymboard-api/src/main/resources/application-development.properties
@@ -9,5 +9,11 @@ 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
+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 b5dcd13..8d7065b 100644
--- a/gymboard-app/src/components/AccountMenuItem.vue
+++ b/gymboard-app/src/components/AccountMenuItem.vue
@@ -10,14 +10,14 @@
- Log out
+ {{ $t('accountMenuItem.logOut') }}
+
+ {{ statusText }}
+
+
+
+
+
+
diff --git a/gymboard-app/src/pages/LoginPage.vue b/gymboard-app/src/pages/auth/LoginPage.vue
similarity index 76%
rename from gymboard-app/src/pages/LoginPage.vue
rename to gymboard-app/src/pages/auth/LoginPage.vue
index ce07ac5..6e5d769 100644
--- a/gymboard-app/src/pages/LoginPage.vue
+++ b/gymboard-app/src/pages/auth/LoginPage.vue
@@ -1,12 +1,13 @@
- Login to Gymboard
+ {{ $t('loginPage.title') }}
@@ -27,10 +28,15 @@
-
+
-
+
+ {{ $t('loginPage.createAccount') }}
+
@@ -38,7 +44,7 @@
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 @@
+
+
+ {{ $t('registrationSuccessPage.title') }}
+ Check your email for the link to activate your account.
+ You may safely close this page.
+
+
+
+
+
+
diff --git a/gymboard-app/src/router/routes.ts b/gymboard-app/src/router/routes.ts
index 3ae9fc4..20de51f 100644
--- a/gymboard-app/src/router/routes.ts
+++ b/gymboard-app/src/router/routes.ts
@@ -6,9 +6,19 @@ 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';
-import LoginPage from 'pages/LoginPage.vue';
+import LoginPage from 'pages/auth/LoginPage.vue';
+import RegisterPage from "pages/auth/RegisterPage.vue";
+import RegistrationSuccessPage from "pages/auth/RegistrationSuccessPage.vue";
+import ActivationPage from "pages/auth/ActivationPage.vue";
const routes: RouteRecordRaw[] = [
+ // Auth-related pages, which live outside the main layout.
+ { path: '/login', component: LoginPage },
+ { path: '/register', component: RegisterPage },
+ { path: '/register/success', component: RegistrationSuccessPage },
+ { path: '/activate', component: ActivationPage },
+
+ // Main app:
{
path: '/',
component: MainLayout,
@@ -26,7 +36,6 @@ const routes: RouteRecordRaw[] = [
},
],
},
- { path: '/login', component: LoginPage },
// Always leave this as last one,
// but you can also remove it