Added proper login and registration flows.
This commit is contained in:
parent
6b1e20d544
commit
c58e29cbd8
|
@ -12,3 +12,12 @@ services:
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: gymboard-api-dev
|
POSTGRES_USER: gymboard-api-dev
|
||||||
POSTGRES_PASSWORD: testpass
|
POSTGRES_PASSWORD: testpass
|
||||||
|
|
||||||
|
mailhog:
|
||||||
|
image: mailhog/mailhog
|
||||||
|
restart: always
|
||||||
|
expose:
|
||||||
|
- "8025"
|
||||||
|
ports:
|
||||||
|
- "1025:1025" # SMTP
|
||||||
|
- "8025:8025" # Web UI
|
||||||
|
|
|
@ -37,6 +37,10 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
|
|
|
@ -51,7 +51,9 @@ public class SecurityConfig {
|
||||||
HttpMethod.POST,
|
HttpMethod.POST,
|
||||||
"/gyms/submissions",
|
"/gyms/submissions",
|
||||||
"/gyms/submissions/upload",
|
"/gyms/submissions/upload",
|
||||||
"/auth/token"
|
"/auth/token",
|
||||||
|
"/auth/register",
|
||||||
|
"/auth/activate"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
// Everything else must be authenticated, just to be safe.
|
// Everything else must be authenticated, just to be safe.
|
||||||
.anyRequest().authenticated();
|
.anyRequest().authenticated();
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
package nl.andrewlalis.gymboard_api.controller;
|
package nl.andrewlalis.gymboard_api.controller;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials;
|
import nl.andrewlalis.gymboard_api.controller.dto.*;
|
||||||
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.model.auth.User;
|
||||||
import nl.andrewlalis.gymboard_api.service.auth.TokenService;
|
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.Authentication;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
@ -15,9 +14,35 @@ import org.springframework.web.bind.annotation.RestController;
|
||||||
@RestController
|
@RestController
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
private final TokenService tokenService;
|
private final TokenService tokenService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
public AuthController(TokenService tokenService) {
|
public AuthController(TokenService tokenService, UserService userService) {
|
||||||
this.tokenService = tokenService;
|
this.tokenService = tokenService;
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint for registering a new user in the system. <strong>This is a
|
||||||
|
* public endpoint.</strong> 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. <strong>This
|
||||||
|
* is a public endpoint.</strong> 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||||
|
|
||||||
|
public record UserActivationPayload(String code) {}
|
|
@ -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<UserActivationCode, Long> {
|
||||||
|
Optional<UserActivationCode> findByCode(String code);
|
||||||
|
}
|
|
@ -144,7 +144,7 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
||||||
String[] roleNames = record.get(3).split("\\s*\\|\\s*");
|
String[] roleNames = record.get(3).split("\\s*\\|\\s*");
|
||||||
|
|
||||||
UserCreationPayload payload = new UserCreationPayload(email, password, name);
|
UserCreationPayload payload = new UserCreationPayload(email, password, name);
|
||||||
var resp = userService.createUser(payload);
|
var resp = userService.createUser(payload, false);
|
||||||
User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow();
|
User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow();
|
||||||
for (var roleName : roleNames) {
|
for (var roleName : roleNames) {
|
||||||
if (roleName.isBlank()) continue;
|
if (roleName.isBlank()) continue;
|
||||||
|
|
|
@ -60,6 +60,10 @@ public class User {
|
||||||
return activated;
|
return activated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setActivated(boolean activated) {
|
||||||
|
this.activated = activated;
|
||||||
|
}
|
||||||
|
|
||||||
public String getEmail() {
|
public String getEmail() {
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.model.auth;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "auth_user_activation_code")
|
||||||
|
public class UserActivationCode {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, updatable = false, length = 127)
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
public UserActivationCode() {}
|
||||||
|
|
||||||
|
public UserActivationCode(User user, String code) {
|
||||||
|
this.user = user;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,26 +1,54 @@
|
||||||
package nl.andrewlalis.gymboard_api.service.auth;
|
package nl.andrewlalis.gymboard_api.service.auth;
|
||||||
|
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import nl.andrewlalis.gymboard_api.controller.dto.UserActivationPayload;
|
||||||
import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload;
|
import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload;
|
||||||
import nl.andrewlalis.gymboard_api.controller.dto.UserResponse;
|
import nl.andrewlalis.gymboard_api.controller.dto.UserResponse;
|
||||||
|
import nl.andrewlalis.gymboard_api.dao.auth.UserActivationCodeRepository;
|
||||||
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
|
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository;
|
||||||
import nl.andrewlalis.gymboard_api.model.auth.User;
|
import nl.andrewlalis.gymboard_api.model.auth.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.model.auth.UserActivationCode;
|
||||||
import nl.andrewlalis.gymboard_api.util.ULID;
|
import nl.andrewlalis.gymboard_api.util.ULID;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UserService {
|
public class UserService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UserService.class);
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final UserActivationCodeRepository activationCodeRepository;
|
||||||
private final ULID ulid;
|
private final ULID ulid;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
|
||||||
public UserService(UserRepository userRepository, ULID ulid, PasswordEncoder passwordEncoder) {
|
@Value("${app.web-origin}")
|
||||||
|
private String webOrigin;
|
||||||
|
|
||||||
|
public UserService(
|
||||||
|
UserRepository userRepository,
|
||||||
|
UserActivationCodeRepository activationCodeRepository, ULID ulid,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JavaMailSender mailSender
|
||||||
|
) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
|
this.activationCodeRepository = activationCodeRepository;
|
||||||
this.ulid = ulid;
|
this.ulid = ulid;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.mailSender = mailSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
@ -31,15 +59,71 @@ public class UserService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public UserResponse createUser(UserCreationPayload payload) {
|
public UserResponse createUser(UserCreationPayload payload, boolean requireActivation) {
|
||||||
// TODO: Validate user payload.
|
// TODO: Validate user payload.
|
||||||
User user = userRepository.save(new User(
|
User user = userRepository.save(new User(
|
||||||
ulid.nextULID(),
|
ulid.nextULID(),
|
||||||
true, // TODO: Change this to false once email activation is in.
|
!requireActivation,
|
||||||
payload.email(),
|
payload.email(),
|
||||||
passwordEncoder.encode(payload.password()),
|
passwordEncoder.encode(payload.password()),
|
||||||
payload.name()
|
payload.name()
|
||||||
));
|
));
|
||||||
|
if (requireActivation) {
|
||||||
|
generateAndSendActivationCode(user);
|
||||||
|
}
|
||||||
|
return new UserResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generateAndSendActivationCode(User user) {
|
||||||
|
Random random = new SecureRandom();
|
||||||
|
StringBuilder sb = new StringBuilder(127);
|
||||||
|
final String alphabet = "bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ23456789";
|
||||||
|
for (int i = 0; i < 127; i++) {
|
||||||
|
sb.append(alphabet.charAt(random.nextInt(alphabet.length())));
|
||||||
|
}
|
||||||
|
String rawCode = sb.toString();
|
||||||
|
UserActivationCode activationCode = activationCodeRepository.save(new UserActivationCode(user, rawCode));
|
||||||
|
// Send email.
|
||||||
|
String activationLink = webOrigin + "/activate?code=" + activationCode.getCode();
|
||||||
|
String emailContent = String.format(
|
||||||
|
"""
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Thank you for registering a new account at Gymboard!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please click <a href="%s">here</a> to activate your account.
|
||||||
|
</p>
|
||||||
|
""",
|
||||||
|
user.getName(),
|
||||||
|
activationLink
|
||||||
|
);
|
||||||
|
MimeMessage msg = mailSender.createMimeMessage();
|
||||||
|
try {
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(msg, "UTF-8");
|
||||||
|
helper.setFrom("Gymboard <noreply@gymboard.io>");
|
||||||
|
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);
|
return new UserResponse(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,5 +9,11 @@ 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
|
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
|
||||||
|
|
||||||
|
|
|
@ -111,7 +111,9 @@ module.exports = configure(function (ctx) {
|
||||||
// directives: [],
|
// directives: [],
|
||||||
|
|
||||||
// Quasar plugins
|
// Quasar plugins
|
||||||
plugins: [],
|
plugins: [
|
||||||
|
'Notify'
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// animations: 'all', // --- includes all animations
|
// animations: 'all', // --- includes all animations
|
||||||
|
|
|
@ -14,6 +14,12 @@ export interface TokenCredentials {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserCreationPayload {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
class AuthModule {
|
class AuthModule {
|
||||||
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
|
private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
@ -35,6 +41,16 @@ class AuthModule {
|
||||||
clearTimeout(this.tokenRefreshTimer);
|
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<User> {
|
||||||
|
const response = await api.post('/auth/activate', {code: code});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchNewToken(credentials: TokenCredentials): Promise<string> {
|
private async fetchNewToken(credentials: TokenCredentials): Promise<string> {
|
||||||
const response = await api.post('/auth/token', credentials);
|
const response = await api.post('/auth/token', credentials);
|
||||||
return response.data.token;
|
return response.data.token;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { boot } from 'quasar/wrappers';
|
import {boot} from 'quasar/wrappers';
|
||||||
import { createI18n } from 'vue-i18n';
|
import {createI18n} from 'vue-i18n';
|
||||||
|
|
||||||
import messages from 'src/i18n';
|
import messages from 'src/i18n';
|
||||||
|
|
||||||
|
@ -28,6 +28,17 @@ export default boot(({ app }) => {
|
||||||
messages,
|
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
|
// Set i18n instance on app
|
||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,14 +10,14 @@
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item clickable v-close-popup @click="api.auth.logout(authStore)">
|
<q-item clickable v-close-popup @click="api.auth.logout(authStore)">
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>Log out</q-item-label>
|
<q-item-label>{{ $t('accountMenuItem.logOut') }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-btn-dropdown>
|
</q-btn-dropdown>
|
||||||
<q-btn
|
<q-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
:label="$t('Login')"
|
:label="$t('accountMenuItem.logIn')"
|
||||||
v-if="!authStore.loggedIn"
|
v-if="!authStore.loggedIn"
|
||||||
no-caps
|
no-caps
|
||||||
icon="person"
|
icon="person"
|
||||||
|
|
|
@ -3,6 +3,21 @@ export default {
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
pages: 'Pages',
|
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: {
|
indexPage: {
|
||||||
searchHint: 'Search for a Gym',
|
searchHint: 'Search for a Gym',
|
||||||
},
|
},
|
||||||
|
@ -24,4 +39,8 @@ export default {
|
||||||
submit: 'Submit',
|
submit: 'Submit',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
accountMenuItem: {
|
||||||
|
logIn: 'Login',
|
||||||
|
logOut: 'Log out'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,6 +3,21 @@ export default {
|
||||||
language: 'Taal',
|
language: 'Taal',
|
||||||
pages: "Pagina's",
|
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: {
|
indexPage: {
|
||||||
searchHint: 'Zoek een sportschool',
|
searchHint: 'Zoek een sportschool',
|
||||||
},
|
},
|
||||||
|
@ -24,4 +39,8 @@ export default {
|
||||||
submit: 'Sturen',
|
submit: 'Sturen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
accountMenuItem: {
|
||||||
|
logIn: 'Inloggen',
|
||||||
|
logOut: 'Uitloggen'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
<template>
|
||||||
|
<StandardCenteredPage>
|
||||||
|
<h3 class="text-center">{{ statusText }}</h3>
|
||||||
|
</StandardCenteredPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
|
import {onMounted, ref} from 'vue';
|
||||||
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
|
import api from 'src/api/main';
|
||||||
|
import {sleep} from 'src/utils';
|
||||||
|
import {useI18n} from 'vue-i18n';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const t = useI18n().t;
|
||||||
|
|
||||||
|
const statusText = ref('');
|
||||||
|
statusText.value = t('activationPage.activating');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await sleep(500);
|
||||||
|
const code = route.query.code as string;
|
||||||
|
try {
|
||||||
|
const user = api.auth.activateUser(code);
|
||||||
|
statusText.value = t('activationPage.success');
|
||||||
|
console.log(user);
|
||||||
|
await sleep(2000);
|
||||||
|
await router.replace('/login');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
statusText.value = t('activationPage.failure');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -1,12 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<StandardCenteredPage>
|
<StandardCenteredPage>
|
||||||
<h3 class="text-center">Login to Gymboard</h3>
|
<h3 class="text-center">{{ $t('loginPage.title') }}</h3>
|
||||||
<q-form @submit="tryLogin" @reset="resetLogin">
|
<q-form @submit="tryLogin" @reset="resetLogin">
|
||||||
<SlimForm>
|
<SlimForm>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<q-input
|
<q-input
|
||||||
:label="$t('loginPage.email')"
|
:label="$t('loginPage.email')"
|
||||||
v-model="loginModel.email"
|
v-model="loginModel.email"
|
||||||
|
type="email"
|
||||||
class="col-12"
|
class="col-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -27,10 +28,15 @@
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<q-btn type="submit" label="Log in" color="primary" class="q-mt-md col-12" no-caps/>
|
<q-btn type="submit" :label="$t('loginPage.logIn')" color="primary" class="q-mt-md col-12" no-caps/>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<q-btn flat no-caps label="Create an account" color="secondary" class="q-mt-md col-12" style="text-decoration: underline"/>
|
<router-link
|
||||||
|
:to="{ path: '/register', query: route.query.next ? { next: route.query.next } : {} }"
|
||||||
|
class="q-mt-md text-primary text-center col-12"
|
||||||
|
>
|
||||||
|
{{ $t('loginPage.createAccount') }}
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</SlimForm>
|
</SlimForm>
|
||||||
</q-form>
|
</q-form>
|
||||||
|
@ -38,7 +44,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
import SlimForm from 'components/SlimForm.vue';
|
import SlimForm from 'components/SlimForm.vue';
|
||||||
import {ref} from 'vue';
|
import {ref} from 'vue';
|
||||||
import api from 'src/api/main';
|
import api from 'src/api/main';
|
||||||
|
@ -56,7 +62,6 @@ const loginModel = ref({
|
||||||
const passwordVisible = ref(false);
|
const passwordVisible = ref(false);
|
||||||
|
|
||||||
async function tryLogin() {
|
async function tryLogin() {
|
||||||
console.log('logging in...');
|
|
||||||
try {
|
try {
|
||||||
await api.auth.login(authStore, loginModel.value);
|
await api.auth.login(authStore, loginModel.value);
|
||||||
const dest = route.query.next ? decodeURIComponent(route.query.next as string) : '/';
|
const dest = route.query.next ? decodeURIComponent(route.query.next as string) : '/';
|
||||||
|
@ -70,7 +75,6 @@ function resetLogin() {
|
||||||
loginModel.value.email = '';
|
loginModel.value.email = '';
|
||||||
loginModel.value.password = '';
|
loginModel.value.password = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
|
@ -0,0 +1,94 @@
|
||||||
|
<template>
|
||||||
|
<StandardCenteredPage>
|
||||||
|
<h3 class="text-center">{{ $t('registerPage.title') }}</h3>
|
||||||
|
<q-form @submit="tryRegister" @reset="resetForm">
|
||||||
|
<SlimForm>
|
||||||
|
<div class="row">
|
||||||
|
<q-input
|
||||||
|
:label="$t('registerPage.name')"
|
||||||
|
v-model="registerModel.name"
|
||||||
|
type="text"
|
||||||
|
class="col-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<q-input
|
||||||
|
:label="$t('registerPage.email')"
|
||||||
|
v-model="registerModel.email"
|
||||||
|
type="email"
|
||||||
|
class="col-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<q-input
|
||||||
|
:label="$t('registerPage.password')"
|
||||||
|
v-model="registerModel.password"
|
||||||
|
:type="passwordVisible ? 'text' : 'password'"
|
||||||
|
class="col-12"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="passwordVisible ? 'visibility' : 'visibility_off'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="passwordVisible = !passwordVisible"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
:label="$t('registerPage.register')"
|
||||||
|
color="primary"
|
||||||
|
class="q-mt-md col-12"
|
||||||
|
no-caps
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SlimForm>
|
||||||
|
</q-form>
|
||||||
|
</StandardCenteredPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SlimForm from 'components/SlimForm.vue';
|
||||||
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
|
import api from 'src/api/main';
|
||||||
|
import {useRouter} from 'vue-router';
|
||||||
|
import {ref} from 'vue';
|
||||||
|
import {useQuasar} from 'quasar';
|
||||||
|
import {useI18n} from 'vue-i18n';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const registerModel = ref({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const passwordVisible = ref(false);
|
||||||
|
|
||||||
|
const t = useI18n().t;
|
||||||
|
const quasar = useQuasar();
|
||||||
|
|
||||||
|
async function tryRegister() {
|
||||||
|
try {
|
||||||
|
await api.auth.register(registerModel.value);
|
||||||
|
await router.push('/register/success');
|
||||||
|
} catch (error) {
|
||||||
|
quasar.notify({
|
||||||
|
message: t('registerPage.error'),
|
||||||
|
type: 'negative'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
registerModel.value.name = '';
|
||||||
|
registerModel.value.email = '';
|
||||||
|
registerModel.value.password = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,15 @@
|
||||||
|
<template>
|
||||||
|
<StandardCenteredPage>
|
||||||
|
<h3 class="text-center">{{ $t('registrationSuccessPage.title') }}</h3>
|
||||||
|
<p>Check your email for the link to activate your account.</p>
|
||||||
|
<p>You may safely close this page.</p>
|
||||||
|
</StandardCenteredPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -6,9 +6,19 @@ 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';
|
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[] = [
|
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: '/',
|
path: '/',
|
||||||
component: MainLayout,
|
component: MainLayout,
|
||||||
|
@ -26,7 +36,6 @@ const routes: RouteRecordRaw[] = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/login', component: LoginPage },
|
|
||||||
|
|
||||||
// Always leave this as last one,
|
// Always leave this as last one,
|
||||||
// but you can also remove it
|
// but you can also remove it
|
||||||
|
|
Loading…
Reference in New Issue