Changed to Redis Session; Further implemented FE

This commit is contained in:
Baer 2024-08-18 16:26:57 +02:00
parent 2550140811
commit 77cfaf35ed
39 changed files with 1423 additions and 1221 deletions

View File

@ -26,7 +26,6 @@ repositories {
mavenCentral() mavenCentral()
} }
val jjwtVersion: String = "0.12.6";
val bcVersion: String = "1.78.1"; val bcVersion: String = "1.78.1";
dependencies { dependencies {
@ -34,15 +33,15 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-websocket") implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.springframework.boot:spring-boot-docker-compose")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.session:spring-session-data-redis")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion")
implementation("org.bouncycastle:bcprov-jdk18on:$bcVersion") implementation("org.bouncycastle:bcprov-jdk18on:$bcVersion")
compileOnly("org.projectlombok:lombok") compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")
runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion")
annotationProcessor("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")

7
docker-compose.yml Normal file
View File

@ -0,0 +1,7 @@
version: "3.1"
services:
redis:
image: "redis:alpine"
ports:
- "6379:6379"

View File

@ -4,9 +4,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("application") @ConfigurationProperties("application")
data class ApplicationProperties( data class ApplicationProperties(
val test: Boolean,
val corsAllowedOrigins: List<String>, val corsAllowedOrigins: List<String>,
val corsAllowedMethods: List<String>, val corsAllowedMethods: List<String>,
val jwtCookieName: String,
val jwtExpirationMs: Long,
val jwtSecret: String,
) )

View File

@ -2,21 +2,23 @@ package at.eisibaer.jbear2.endpoint
import at.eisibaer.jbear2.dto.auth.LoginDto import at.eisibaer.jbear2.dto.auth.LoginDto
import at.eisibaer.jbear2.dto.auth.LoginResponseDto import at.eisibaer.jbear2.dto.auth.LoginResponseDto
import at.eisibaer.jbear2.model.Board
import at.eisibaer.jbear2.model.User import at.eisibaer.jbear2.model.User
import at.eisibaer.jbear2.repository.UserRepository import at.eisibaer.jbear2.repository.UserRepository
import at.eisibaer.jbear2.security.jwt.JwtUtils import at.eisibaer.jbear2.security.UserDetailsImpl
import at.eisibaer.jbear2.security.userdetail.UserDetailsImpl import at.eisibaer.jbear2.util.Constants.STR_SESSION_USER_KEY
import jakarta.servlet.http.HttpSession
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseCookie
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.AuthenticationException
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@ -27,34 +29,27 @@ class AuthEndpoint(
val authenticationManager: AuthenticationManager, val authenticationManager: AuthenticationManager,
val userRepository: UserRepository, val userRepository: UserRepository,
val encoder: PasswordEncoder, val encoder: PasswordEncoder,
val jwtUtils: JwtUtils,
) { ) {
private val log: Logger = LoggerFactory.getLogger(AuthEndpoint::class.java); private val log: Logger = LoggerFactory.getLogger(AuthEndpoint::class.java);
val strResponseSuccess: String = "Sending back success response"; val strResponseSuccess: String = "Sending back success response";
val strAlreadyLoggedIn: String = "User already logged in";
@PostMapping("/signup") @PostMapping("/signup")
fun signupUser(@RequestBody loginDto: LoginDto): ResponseEntity<String>{ fun signupUser(@RequestBody loginDto: LoginDto, session: HttpSession): ResponseEntity<*>{
log.info("Endpoint singupUser called"); log.info("Endpoint signupUser called");
log.debug("signup Request with username: {}", loginDto.username); log.debug("signup Request with username: {}", loginDto.username);
if( userRepository.existsByUsername(loginDto.username)){ if( userRepository.existsByUsername(loginDto.username)){
log.info("Username was already taken"); log.info("Username was already taken");
ResponseEntity.badRequest().body("Username already taken"); return ResponseEntity.badRequest().body("Username already taken");
} }
val user = User(loginDto.username, encoder.encode( loginDto.password), ArrayList(), null, null ); val user = User(loginDto.username, encoder.encode( loginDto.password), ArrayList(), null, null );
userRepository.save(user); userRepository.save(user);
log.info(strResponseSuccess);
return ResponseEntity.ok().body("User registered successfully");
}
@PostMapping("/login")
fun loginUser(@RequestBody loginDto: LoginDto): ResponseEntity<LoginResponseDto>{
log.info("Endpoint loginUser called");
log.debug("login Request with username: {}", loginDto.username);
val authentication = authenticationManager.authenticate( val authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken(
loginDto.username, loginDto.username,
@ -66,22 +61,77 @@ class AuthEndpoint(
val userDetails: UserDetailsImpl = authentication.principal as UserDetailsImpl; val userDetails: UserDetailsImpl = authentication.principal as UserDetailsImpl;
val jwtCookie = jwtUtils.generateJwtCookie(userDetails); session.setAttribute(STR_SESSION_USER_KEY, userDetails);
log.info(strResponseSuccess);
return ResponseEntity.ok().body(LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename()));
}
@PostMapping("/login")
fun loginUser(@RequestBody loginDto: LoginDto, session: HttpSession): ResponseEntity<*>{
log.info("Endpoint loginUser called");
log.debug("login Request with username: {}", loginDto.username);
if( session.getAttribute(STR_SESSION_USER_KEY) != null ){
log.info(strAlreadyLoggedIn);
return ResponseEntity.badRequest().body(strAlreadyLoggedIn);
}
val authentication: Authentication;
try{
authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken(
loginDto.username,
loginDto.password
)
)
} catch (authenticationException: AuthenticationException ){
if( authenticationException is BadCredentialsException ){
log.debug("Login attempt with bad credentials for username: {}", loginDto.username);
return ResponseEntity.status(401).body(4011);
}
log.error("Error during authentication", authenticationException);
return ResponseEntity.status(401).body(4010);
}
SecurityContextHolder.getContext().authentication = authentication;
val userDetails: UserDetailsImpl = authentication.principal as UserDetailsImpl;
session.setAttribute(STR_SESSION_USER_KEY, userDetails);
log.info(strResponseSuccess); log.info(strResponseSuccess);
return ResponseEntity.ok() return ResponseEntity.ok()
.header( HttpHeaders.SET_COOKIE, jwtCookie.toString() )
.body( LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename())) .body( LoginResponseDto(userDetails.username, userDetails.getProfilePictureFilename()))
} }
@PostMapping("signout") @PostMapping("/logout")
fun logoutUser(): ResponseEntity<String>{ fun logoutUser(session: HttpSession): ResponseEntity<String>{
log.info("Endpoint logoutUser called"); log.info("Endpoint logoutUser called");
val cookie: ResponseCookie = jwtUtils.getCleanJwtCookie();
session.invalidate();
log.info(strResponseSuccess); log.info(strResponseSuccess);
return ResponseEntity.ok() return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body("Logged out"); .body("Logged out");
} }
@GetMapping("/status")
fun checkStatus(session: HttpSession): ResponseEntity<*>{
log.info("Endpoint checkStatus called");
return if( session.getAttribute(STR_SESSION_USER_KEY) != null ){
log.info(strAlreadyLoggedIn);
val sessionUser: UserDetailsImpl = session.getAttribute(STR_SESSION_USER_KEY) as UserDetailsImpl;
ResponseEntity
.ok()
.body( LoginResponseDto( sessionUser.username, sessionUser.getProfilePictureFilename() ) );
} else {
log.debug("No user logged in");
log.info(strResponseSuccess);
ResponseEntity
.status(401)
.body("No user logged in");
}
}
} }

View File

@ -20,4 +20,9 @@ class UserEndpoint {
log.info("test Endpoint!"); log.info("test Endpoint!");
return ResponseEntity.ok(param1); return ResponseEntity.ok(param1);
} }
@GetMapping("/boards")
fun getBoards(){
TODO();
}
} }

View File

@ -1,39 +1,33 @@
package at.eisibaer.jbear2.security.jwt package at.eisibaer.jbear2.security
import at.eisibaer.jbear2.util.Constants.STR_SESSION_USER_KEY
import jakarta.servlet.FilterChain import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import jakarta.servlet.http.HttpSession
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.filter.OncePerRequestFilter
@Component @Component
class AuthTokenFilter( class AuthFilter: OncePerRequestFilter() {
private var jwtUtils: JwtUtils?,
private var userDetailService: UserDetailsService?,
): OncePerRequestFilter() {
val log: Logger = LoggerFactory.getLogger(AuthTokenFilter::class.java); val log: Logger = LoggerFactory.getLogger(AuthFilter::class.java);
override fun doFilterInternal( override fun doFilterInternal(
request: HttpServletRequest, request: HttpServletRequest,
response: HttpServletResponse, response: HttpServletResponse,
filterChain: FilterChain filterChain: FilterChain
) { ) {
val session: HttpSession = request.session;
try{ try{
val jwt: String? = parseJwt(request); val user: UserDetailsImpl? = session.getAttribute(STR_SESSION_USER_KEY) as UserDetailsImpl?;
if( jwt != null && jwtUtils!!.validateJwt(jwt) ){ if( user != null ){
val username: String = jwtUtils!!.getUserNameFromJwt(jwt); val authentication = UsernamePasswordAuthenticationToken(user, null, emptyList());
val userDetails: UserDetails = userDetailService!!.loadUserByUsername( username );
val authentication = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities);
authentication.details = WebAuthenticationDetailsSource().buildDetails(request); authentication.details = WebAuthenticationDetailsSource().buildDetails(request);
SecurityContextHolder.getContext().authentication = authentication; SecurityContextHolder.getContext().authentication = authentication;
@ -44,8 +38,4 @@ class AuthTokenFilter(
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
private fun parseJwt(request: HttpServletRequest): String?{
return jwtUtils!!.getJwtFromCookies(request);
}
} }

View File

@ -1,6 +1,6 @@
package at.eisibaer.jbear2.security package at.eisibaer.jbear2.security
import at.eisibaer.jbear2.security.jwt.AuthTokenFilter import at.eisibaer.jbear2.config.ApplicationProperties
import jakarta.servlet.FilterChain import jakarta.servlet.FilterChain
import jakarta.servlet.ServletException import jakarta.servlet.ServletException
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
@ -14,8 +14,6 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity 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.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
@ -32,33 +30,38 @@ import java.util.function.Supplier
@EnableMethodSecurity @EnableMethodSecurity
class SecurityConfiguration( class SecurityConfiguration(
private val userDetailService: UserDetailsService, private val userDetailService: UserDetailsService,
private val unauthorizedHandler: AuthTokenFilter, private val unauthorizedHandler: AuthFilter,
private val applicationProperties: ApplicationProperties
) { ) {
final val log: Logger = LoggerFactory.getLogger(SecurityConfiguration::class.java); final val log: Logger = LoggerFactory.getLogger(SecurityConfiguration::class.java);
@Bean @Bean
fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain { fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
return httpSecurity return addCsrfConfig(httpSecurity)
.csrf { config -> .authorizeHttpRequests { config ->
config
.requestMatchers("/api/auth/*").permitAll()
.requestMatchers("/api/user/**").authenticated()
.requestMatchers("/**").permitAll()
}
.authenticationProvider(authenticationProvider())
.addFilterBefore(unauthorizedHandler, UsernamePasswordAuthenticationFilter::class.java)
.build()
}
private fun addCsrfConfig(httpSecurity: HttpSecurity): HttpSecurity{
if( applicationProperties.test ){
httpSecurity.csrf{ config -> config.disable()};
} else {
httpSecurity.csrf { config ->
config config
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(SpaCsrfTokenRequestHandler()) .csrfTokenRequestHandler(SpaCsrfTokenRequestHandler())
} }
.addFilterAfter(CsrfCookieFilter(), BasicAuthenticationFilter::class.java) .addFilterAfter(CsrfCookieFilter(), BasicAuthenticationFilter::class.java)
.authorizeHttpRequests { config ->
config
.requestMatchers("/api/auth/*").permitAll()
.requestMatchers("/api/**").authenticated()
.requestMatchers("/profile").authenticated()
.requestMatchers("/**").permitAll()
} }
.sessionManagement { config: SessionManagementConfigurer<HttpSecurity?> -> return httpSecurity;
config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
.authenticationProvider(authenticationProvider())
.addFilterBefore(unauthorizedHandler, UsernamePasswordAuthenticationFilter::class.java)
.build()
} }
class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() { class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() {
@ -94,7 +97,6 @@ class SecurityConfiguration(
} }
class CsrfCookieFilter : OncePerRequestFilter() { class CsrfCookieFilter : OncePerRequestFilter() {
@Throws(ServletException::class, IOException::class) @Throws(ServletException::class, IOException::class)
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
val csrfToken = request.getAttribute("_csrf") as CsrfToken val csrfToken = request.getAttribute("_csrf") as CsrfToken

View File

@ -1,4 +1,4 @@
package at.eisibaer.jbear2.security.userdetail package at.eisibaer.jbear2.security
import at.eisibaer.jbear2.repository.UserRepository import at.eisibaer.jbear2.repository.UserRepository
import at.eisibaer.jbear2.model.User import at.eisibaer.jbear2.model.User

View File

@ -1,4 +1,4 @@
package at.eisibaer.jbear2.security.userdetail package at.eisibaer.jbear2.security
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import lombok.Data import lombok.Data

View File

@ -1,23 +0,0 @@
package at.eisibaer.jbear2.security.jwt
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
class AuthEntryPointJwt: AuthenticationEntryPoint {
val log: Logger = LoggerFactory.getLogger(AuthEntryPointJwt::class.java);
override fun commence(
request: HttpServletRequest?,
response: HttpServletResponse?,
authException: AuthenticationException?
) {
log.error("Unauthorized error: {}", authException?.message);
response?.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized");
}
}

View File

@ -1,75 +0,0 @@
package at.eisibaer.jbear2.security.jwt
import at.eisibaer.jbear2.config.ApplicationProperties
import at.eisibaer.jbear2.security.userdetail.UserDetailsImpl
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.UnsupportedJwtException
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseCookie
import org.springframework.stereotype.Component
import org.springframework.web.util.WebUtils
import java.util.Date
import javax.crypto.SecretKey
@Component
class JwtUtils(
private val applicationProperties: ApplicationProperties
) {
private val log: Logger = LoggerFactory.getLogger(JwtUtils::class.java);
fun getJwtFromCookies(request: HttpServletRequest): String?{
return WebUtils.getCookie(request, applicationProperties.jwtCookieName)?.value;
}
fun generateJwtCookie(userPrincipal: UserDetailsImpl): ResponseCookie{
val jwt: String = generateTokenFromUsername(userPrincipal.username)
return ResponseCookie.from(applicationProperties.jwtCookieName, jwt)
.path("/api")
.maxAge(applicationProperties.jwtExpirationMs/1000)
.httpOnly(true)
.build();
}
fun getCleanJwtCookie(): ResponseCookie {
return ResponseCookie.from(applicationProperties.jwtCookieName, "")
.path("/api")
.build();
}
fun getUserNameFromJwt(token: String): String{
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).payload.subject;
}
fun key(): SecretKey{
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(applicationProperties.jwtSecret));
}
fun generateTokenFromUsername(username: String): String{
return Jwts.builder()
.subject(username)
.issuedAt(Date())
.expiration(Date(Date().time + applicationProperties.jwtExpirationMs))
.signWith(key())
.compact();
}
fun validateJwt(authToken: String): Boolean{
try {
Jwts.parser().verifyWith(key()).build().parseSignedClaims(authToken);
return true;
} catch (e: UnsupportedJwtException) {
log.error("UnsupportedJwtException {}", e.message);
} catch (e: IllegalArgumentException) {
log.error("IllegalArgumentException {}", e.message);
} catch (e: JwtException) {
log.error("JwtException {}", e.message);
}
return false;
}
}

View File

@ -0,0 +1,5 @@
package at.eisibaer.jbear2.util
object Constants {
const val STR_SESSION_USER_KEY = "user"
}

View File

@ -1,13 +1,14 @@
logging: logging:
level: level:
org: at:
springframework: eisibaer:
security: "DEBUG" jbear2: "DEBUG"
spring: spring:
datasource: datasource:
url: jdbc:postgresql://localhost:5499/jeobeardy?currentSchema=jeobeardy-app url: jdbc:postgresql://${PG_HOST}:${PG_PORT}/jeobeardy?currentSchema=jeobeardy-app
username: ${PG_USER} username: ${PG_USER}
password: ${PG_PASSWORD} password: ${PG_PASSWORD}
application: application:
test: true
cors-allowed-origins: [ "http://localhost:5173/" ] cors-allowed-origins: [ "http://localhost:5173/" ]

View File

@ -1,8 +1,9 @@
spring: spring:
datasource: datasource:
url: jdbc:postgresql://localhost:5499/jeobeardy?currentSchema=jeobeardy-app url: jdbc:postgresql://${PG_HOST}:${PG_PORT}/jeobeardy?currentSchema=jeobeardy-app
username: ${PG_USER} username: ${PG_USER}
password: ${PG_PASSWORD} password: ${PG_PASSWORD}
application: application:
test: false
cors-allowed-origins: [] cors-allowed-origins: []

View File

@ -11,6 +11,9 @@ spring:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
default-schema: jeobeardy-app default-schema: jeobeardy-app
open-in-view: false open-in-view: false
docker:
compose:
lifecycle-management: start-only
server: server:
address: localhost address: localhost
@ -18,6 +21,3 @@ server:
application: application:
cors-allowed-methods: ["GET", "POST", "DELETE", "OPTIONS"] cors-allowed-methods: ["GET", "POST", "DELETE", "OPTIONS"]
jwt-cookie-name: "user_jwt"
jwt-expiration-ms: 86400000 #1000 * 60 * 60 * 24 = 1 day
jwt-secret: ${JWT_SECRET}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,7 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Vite App</title>
<script type="module" crossorigin src="/assets/index-CPLpx4lq.js"></script> <script type="module" crossorigin src="/assets/index-VvMfePyX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-21nzev1V.css"> <link rel="stylesheet" crossorigin href="/assets/index-21nzev1V.css">
</head> </head>
<body> <body>

View File

@ -1,37 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { provide, ref } from 'vue';
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import NavBar from '@/components/blocks/NavBar.vue';
import FooterBlock from '@/components/blocks/FooterBlock.vue';
import GenericInfoModal from '@/components/modals/GenericInfoModal.vue';
import { provide, ref } from 'vue';
import { infoModalShowFnKey } from './services/UtilService'; import { infoModalShowFnKey } from './services/UtilService';
const infoModal = ref<InstanceType<typeof GenericInfoModal> | null>(null); import NavBar from '@/components/blocks/NavBar.vue';
import GenericInfoModal from '@/components/modals/GenericInfoModal.vue';
import { useUserStore } from './stores/UserStore';
function showInfoModal(title: string, text: string): void{ const userStore = useUserStore();
if( infoModal.value ){
const userLoading = ref( true );
userStore.userCheckPromise
.finally( () => {
userLoading.value = false;
} );
const infoModal = ref<InstanceType<typeof GenericInfoModal> | null>( null );
function showInfoModal( title: string, text: string ): void {
if( infoModal.value ) {
infoModal.value.modalTitle = title; infoModal.value.modalTitle = title;
infoModal.value.modalText = text; infoModal.value.modalText = text;
infoModal.value.show(); infoModal.value.show();
} else { } else {
console.error('Modal not yet available'); console.error( 'Modal not yet available' );
} }
} }
provide(infoModalShowFnKey, showInfoModal); provide( infoModalShowFnKey, showInfoModal );
</script> </script>
<template> <template>
<div class="vh-100 overflow-y-scroll overflow-x-hidden"> <div class="vh-100 overflow-y-scroll overflow-x-hidden">
<NavBar /> <NavBar :userLoading="userLoading" />
<RouterView /> <RouterView />
<!-- <FooterBlock /> --> <GenericInfoModal ref="infoModal" />
<GenericInfoModal
ref="infoModal"
/>
</div> </div>
</template> </template>

View File

@ -1,3 +1,7 @@
.preserve-breaks { .preserve-breaks {
white-space: preserve-breaks; white-space: preserve-breaks;
} }
.pointer {
cursor: pointer;
}

View File

@ -27,16 +27,10 @@ $primary-accent-dark: $cyclamen;
$secondary-accent: $celestial-blue; $secondary-accent: $celestial-blue;
$secondary-accent-dark: $celestial-blue; $secondary-accent-dark: $celestial-blue;
// $secondary-accent: $jungle-green;
// $secondary-accent-dark: $jungle-green;
/* Bootstrap Colors overrides */ /* Bootstrap Colors overrides */
$primary: $primary-accent; $primary: $primary-accent;
$secondary: $secondary-accent; $secondary: $secondary-accent;
// $success: $green;
// $info: $cyan;
// $warning: $yellow;
// $danger: $red;
$light: $anti-flash-white; $light: $anti-flash-white;
$light-accented: shade-color($anti-flash-white, 10%); $light-accented: shade-color($anti-flash-white, 10%);
$dark: $space-cadet; $dark: $space-cadet;
@ -49,18 +43,15 @@ $body-bg-dark: $space-cadet;
$body-secondary-bg-dark: $dark-accented; $body-secondary-bg-dark: $dark-accented;
$body-bg: $anti-flash-white; $body-bg: $anti-flash-white;
$body-secondary-bg: $light-accented; $body-secondary-bg: $light-accented;
$dropdown-link-hover-bg: $dark-accented;
// $font-size-base: 1.5rem; $modal-fade-transform: scale(.75);
// 3. Include remainder of required Bootstrap stylesheets (including any separate color mode stylesheets) // 3. Include remainder of required Bootstrap stylesheets (including any separate color mode stylesheets)
@import "bootstrap/scss/variables"; @import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark"; @import "bootstrap/scss/variables-dark";
// $navbar-dark-active-color: $primary;
// $navbar-light-active-color: $primary;
/* Bootstrap Color Map adjustments */ /* Bootstrap Color Map adjustments */
$custom-colors: ( $custom-colors: (
"gray": $gray-500, "gray": $gray-500,
@ -73,18 +64,9 @@ $theme-colors: map-merge($theme-colors, $custom-colors);
// 5. Include remainder of required parts // 5. Include remainder of required parts
@import "bootstrap/scss/bootstrap"; @import "bootstrap/scss/bootstrap";
// @import "bootstrap/scss/maps";
// @import "bootstrap/scss/mixins";
// @import "bootstrap/scss/root";
// 6. Optionally include any other parts as needed // 6. Optionally include any other parts as needed
@import "bootstrap/scss/utilities"; @import "bootstrap/scss/utilities";
// @import "bootstrap/scss/reboot";
// @import "bootstrap/scss/type";
// @import "bootstrap/scss/images";
// @import "bootstrap/scss/containers";
// @import "bootstrap/scss/grid";
// @import "bootstrap/scss/helpers";
$utilities: map-merge( $utilities: map-merge(
$utilities, $utilities,
@ -98,7 +80,16 @@ $utilities: map-merge(
), ),
), ),
), ),
) "height": map-merge(
map-get($utilities, "height"),
(
values: map-merge(
map-get(map-get($utilities, "height"), "values"),
(fit-content: fit-content),
),
),
),
),
); );
@ -108,6 +99,4 @@ $utilities: map-merge(
// 8. Add additional custom code here // 8. Add additional custom code here
@import "./exotic_theme.scss"; @import "./exotic_theme.scss";
@import "bootstrap/scss/bootstrap";
@import "../css/main.css"; @import "../css/main.css";

View File

@ -30,11 +30,11 @@ const boards = ref([{
<div class="col"> <div class="col">
<div class="d-flex justify-content-around align-items-center"> <div class="d-flex justify-content-around align-items-center">
<button class="btn btn-sm btn-primary"> <button class="btn btn-sm btn-primary">
<font-awesome-icon :icon="['fas', 'edit']" /> <FontAwesomeIcon :icon="['fas', 'edit']" />
Edit Edit
</button> </button>
<button class="btn btn-sm btn-primary"> <button class="btn btn-sm btn-primary">
<font-awesome-icon :icon="['fas', 'play']" /> <FontAwesomeIcon :icon="['fas', 'play']" />
Play Play
</button> </button>
</div> </div>

View File

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { onMounted, watch } from 'vue'; import { onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const localeLocalStorageKey = 'locale'; const localeLocalStorageKey = 'locale';
const i18n = useI18n(); const i18n = useI18n();
const { t } = i18n;
onMounted( () => { onMounted( () => {
const initialLocale = localStorage.getItem( localeLocalStorageKey ); const initialLocale = localStorage.getItem( localeLocalStorageKey );
@ -23,14 +25,14 @@ watch(
<template> <template>
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown"
{{ $t( `i18n.${$i18n.locale}.name` ) }} aria-expanded="false">
<FontAwesomeIcon :icon="['fas', 'globe']" />
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li v-for=" locale in $i18n.availableLocales " :key="`locale-${locale}`" @click="$i18n.locale = locale" <li v-for="locale in $i18n.availableLocales" :key="`locale-${locale}`">
role="button"> <a @click="$i18n.locale = locale" class="dropdown-item pointer" :class="[{ active: $i18n.locale === locale }]">
<a class="dropdown-item pointer" :class="[{ active: $i18n.locale === locale }]"> {{ t( `i18n.${locale}.name` ) }}
{{ $t( `i18n.${locale}.name` ) }}
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -1,33 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, useRoute } from 'vue-router'; import { ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import IconJeobeardy from '@/components/icons/IconJeobeardy.vue'; import IconJeobeardy from '@/components/icons/IconJeobeardy.vue';
import ThemeChanger from './ThemeChanger.vue'; import ThemeChanger from '@/components/blocks/ThemeChanger.vue';
import LocaleChanger from './LocaleChanger.vue'; import LocaleChanger from '@/components/blocks/LocaleChanger.vue';
import { useUserStore } from '@/stores/UserStore'; import { useUserStore } from '@/stores/UserStore';
import { authService } from '@/services/AuthService';
const navNames = { const navNames = {
HOME: "home", HOME: "home",
ABOUT: "about", ABOUT: "about",
} };
const { t } = useI18n();
const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const isActiveNav = ( navName: string ) => { const isActiveNav = ( navName: string ) => {
switch( navName ){ switch( navName ) {
default:
case navNames.HOME: case navNames.HOME:
return route.name === "home"; return route.name === "home";
case navNames.ABOUT: case navNames.ABOUT:
return route.name === "about"; return route.name === "about";
default:
return route.name === "home";
} }
};
function logoutUser() {
authService.logoutUser()
.then( () => {
userStore.logoutUser();
if( route.meta.requiresAuth ) {
router.push( { name: 'home' } );
}
} )
.catch( ( error ) => {
console.error( error );
} );
} }
const userCheckLoading = ref( true );
userStore.userCheckPromise
.finally( () => {
userCheckLoading.value = false;
} )
</script> </script>
<template> <template>
<nav class="navbar navbar-expand-lg bg-dark-accented"> <nav id="navbar-main" class="navbar navbar-expand-lg bg-dark-accented">
<div class="container px-5"> <div class="container px-5">
<div class="position-absolute start-0 top-50 translate-middle-y d-flex ms-3 gap-3"> <div class="position-absolute start-0 top-50 translate-middle-y d-flex ms-3 gap-3">
@ -45,31 +72,59 @@ const isActiveNav = ( navName: string ) => {
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mb-2 mb-lg-0 d-flex align-items-center justify-content-center w-100"> <ul class="navbar-nav mb-2 mb-lg-0 d-flex align-items-center justify-content-center w-100">
<li class="nav-item"> <li class="nav-item">
<RouterLink to="/" class="nav-link text-light fs-3" :class="[{active: isActiveNav(navNames.HOME)}]" :aria-current="isActiveNav(navNames.HOME) ? 'page' : false">{{ $t( 'nav.home' ) }}</RouterLink> <RouterLink to="/" class="nav-link text-light fs-3"
:aria-current="isActiveNav( navNames.HOME ) ? 'page' : false">{{ t( 'nav.home' ) }}</RouterLink>
</li> </li>
<li class="nav-item px-5 mx-5 rounded-5 py-2"> <li class="nav-item px-5 mx-5 rounded-5 py-2">
<RouterLink to="/" class="nav-link py-0"> <RouterLink to="/" class="nav-link py-0">
<IconJeobeardy height="3rem" width="4rem"/> <IconJeobeardy height="3rem" width="4rem" />
</RouterLink> </RouterLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<RouterLink to="/about" class="nav-link text-light fs-3" :class="[{active: isActiveNav(navNames.ABOUT)}]" :aria-current="isActiveNav(navNames.HOME) ? 'page' : false">{{ $t( 'nav.about' ) }}</RouterLink> <RouterLink to="/about" class="nav-link text-light fs-3"
:aria-current="isActiveNav( navNames.HOME ) ? 'page' : false">{{ t( 'nav.about' ) }}</RouterLink>
</li> </li>
</ul> </ul>
</div> </div>
<div class="position-absolute end-0 top-50 translate-middle-y d-flex me-3"> <div class="position-absolute end-0 top-50 translate-middle-y d-flex me-3 align-items-center">
<div v-if="userStore.loggedIn"> <template v-if=" userCheckLoading ">
{{ userStore.getUserOutput }}
</template>
<template v-else-if=" userStore.loggedIn ">
<div class="dropdown-toggle pointer" data-bs-toggle="dropdown" aria-expanded="false">
<img class="pfp-sizing rounded-circle border border-1 border-primary" :src="userStore.pfpSource"
alt="The Profile Pic of the user" />
</div> </div>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<p class="dropdown-header fs-5 pt-0 text-primary fw-semibold">{{ userStore.getUserOutput }}</p>
</li>
<li>
<RouterLink class="dropdown-item" to="/profile" :class="[{ 'active': route.name === 'profile' }]">Profile
</RouterLink>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li><span class="dropdown-item pointer text-danger" @click="logoutUser">Logout</span></li>
</ul>
</template>
<div v-else> <div v-else>
<RouterLink to="/login" class="btn btn-sm btn-outline-primary"> <RouterLink to="/login" class="btn btn-sm btn-outline-primary">
Login Login
</RouterLink> </RouterLink>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
</template> </template>
<style lang="css" scoped>
.pfp-sizing {
width: 2.5rem;
height: 2.5rem;
object-fit: cover;
}
</style>

View File

@ -1,22 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBootstrapTheme } from '@/composables/colorTheme'; import { useBootstrapTheme } from '@/composables/colorTheme';
import { useI18n } from 'vue-i18n';
const { availableThemes, currentTheme } = useBootstrapTheme(); const { availableThemes, currentTheme } = useBootstrapTheme();
const { t } = useI18n();
</script> </script>
<template> <template>
<div class="theme-changer"> <div class="theme-changer">
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<FontAwesomeIcon :icon="currentTheme.icon" /> <FontAwesomeIcon :icon="currentTheme.icon" />
{{ $t( currentTheme.name ) }} <!-- {{ t( currentTheme.name ) }} -->
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li v-for="theme in availableThemes" :key="`theme-${theme.bsName}`" @click="currentTheme = theme" role="button"> <li v-for="theme in availableThemes" :key="`theme-${theme.bsName}`">
<a class="dropdown-item pointer" :class="[{active: theme.bsName === currentTheme.bsName}]"> <a class="dropdown-item pointer" :class="[{ active: theme.bsName === currentTheme.bsName }]"
@click="currentTheme = theme">
<FontAwesomeIcon :icon="theme.icon" /> <FontAwesomeIcon :icon="theme.icon" />
{{ $t(theme.name) }} {{ t( theme.name ) }}
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { Modal } from 'bootstrap';
import { onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
modalId: {
type: String,
required: true,
}
})
const modalRef = ref<null | Element>(null);
let modalInstance: null | Modal;
onMounted( () => {
modalInstance = Modal.getOrCreateInstance(modalRef.value as Element);
});
onUnmounted( () => {
modalInstance?.dispose();
});
function show(){
if( modalInstance ){
modalInstance.show();
} else {
console.error("Modal was not properly created before showing");
}
}
function hide(){
if( modalInstance ){
modalInstance.hide();
} else {
console.error("Modal was not properly created before hiding");
}
}
defineExpose({
show,
hide,
});
</script>
<template>
<div class="modal fade" tabindex="-1" ref="modalRef" :id="props.modalId">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('profile.edit.title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>TODO</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary" data-bs-dismiss="modal">{{ t("common.buttons.close") }}</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">{{ t("common.buttons.saveAndExit") }}</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,8 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { userService } from '@/services/UserService'; import { useI18n } from 'vue-i18n';
import IconJeobeardy from '../icons/IconJeobeardy.vue';
import { userService } from '@/services/UserService';
import IconJeobeardy from '@/components/icons/IconJeobeardy.vue';
const { t } = useI18n();
const testResponse = ref( {} ); const testResponse = ref( {} );
@ -20,7 +24,7 @@ onMounted( () => {
<div class="col"> <div class="col">
<div class="w-100 d-flex justify-content-center my-5"> <div class="w-100 d-flex justify-content-center my-5">
<h1> <h1>
{{ $t( 'home.welcome' ) }} {{ t( 'home.welcome' ) }}
{{ testResponse }} {{ testResponse }}
</h1> </h1>
</div> </div>
@ -32,51 +36,51 @@ onMounted( () => {
<div class="col-md-6 col-12 px-0 bg-body-secondary"> <div class="col-md-6 col-12 px-0 bg-body-secondary">
<div class="d-flex justify-content-center align-items-center h-100 w-100 flex-column"> <div class="d-flex justify-content-center align-items-center h-100 w-100 flex-column">
<h3 class="m-1"> <h3 class="m-1">
{{ $t("join.text") }} {{ t("join.text") }}
</h3> </h3>
<p> <p>
{{ $t("join.alreadyHostedGome") }} {{ t("join.alreadyHostedGome") }}
</p> </p>
<p> <p>
{{ $t("join.textCode") }} {{ t("join.textCode") }}
</p> </p>
<div class="row"> <div class="row">
<div class="col-auto"> <div class="col-auto">
<input type="text" class="form-control" placeholder="Code"> <input type="text" class="form-control" placeholder="Code">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-primary">{{ $t("join.button") }}</button> <button class="btn btn-primary">{{ t("join.button") }}</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 col-12 px-0 mx-0"> <div class="col-md-6 col-12 px-0 mx-0">
<img class="w-100" src="/src/assets/images/OldInGameBlurredRotated.jpeg" <img class="w-100" src="/src/assets/images/OldInGameBlurredRotated.jpeg"
alt="Blurred, slightly tilted image of how the game looks like"> alt="Blurred, slightly tilted view of how a board looks like">
</div> </div>
</div> </div>
<div class="row w-100 border-bottom"> <div class="row w-100 border-bottom">
<div class="col-md-6 col-12 px-0 mx-0"> <div class="col-md-6 col-12 px-0 mx-0">
<img class="w-100" src="/src/assets/images/OldInGameBlurredRotated.jpeg" <img class="w-100" src="/src/assets/images/OldInGameBlurredRotated.jpeg"
alt="Blurred, slightly tilted image of how the game looks like"> alt="Blurred, slightly tilted view of how the a board looks like">
</div> </div>
<div class="col-md-6 col-12 px-0 mx-0 bg-body-secondary"> <div class="col-md-6 col-12 px-0 mx-0 bg-body-secondary">
<div class="h-100 w-100 d-flex justify-content-center align-items-center flex-column"> <div class="h-100 w-100 d-flex justify-content-center align-items-center flex-column">
<h3 class="m-1"> <h3 class="m-1">
{{ $t("host.text") }} {{ t("host.text") }}
</h3> </h3>
<p> <p>
{{ $t("host.alreadyHostedGome") }} {{ t("host.alreadyHostedGome") }}
</p> </p>
<p> <p>
{{ $t("host.textCode") }} {{ t("host.textCode") }}
</p> </p>
<div class="row"> <div class="row">
<div class="col-auto"> <div class="col-auto">
<input type="text" class="form-control" placeholder="Code"> <input type="text" class="form-control" placeholder="Code">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-primary">{{ $t("host.button") }}</button> <button class="btn btn-primary">{{ t("host.button") }}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,35 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LoginDto } from '@/models/dto/LoginDto'; import type { LoginDto } from '@/models/dto/LoginDto';
import type { User } from '@/models/user/User';
import { authService } from '@/services/AuthService'; import { authService } from '@/services/AuthService';
import { infoModalShowFnKey } from '@/services/UtilService'; import { infoModalShowFnKey } from '@/services/UtilService';
import { useUserStore } from '@/stores/UserStore'; import { useUserStore } from '@/stores/UserStore';
import useVuelidate from '@vuelidate/core'; import useVuelidate from '@vuelidate/core';
import { createI18nMessage, required } from '@vuelidate/validators'; import { createI18nMessage, required } from '@vuelidate/validators';
import { AxiosError } from 'axios';
import { computed, inject, ref } from 'vue'; import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { RouterLink, useRouter } from 'vue-router'; import { RouterLink, useRoute, useRouter } from 'vue-router';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const showInfoModal = inject(infoModalShowFnKey); const showInfoModal = inject( infoModalShowFnKey );
const username = ref(''); const username = ref( '' );
const password = ref(''); const password = ref( '' );
const errorMessage = ref(''); const errorMessage = ref( '' );
const loginInProgress = ref(false); const loginInProgress = ref( false );
function loginUser(){ function loginUser() {
v$.value.$touch(); v$.value.$touch();
if(v$.value.$error){ if( v$.value.$error ) {
errorMessage.value = t('forms.validate-fields'); showErrorMessage(t( 'forms.validate-fields' ));
setTimeout(() => {
errorMessage.value = '';
}, 3000);
return; return;
} else { } else {
errorMessage.value = ''; errorMessage.value = '';
@ -38,31 +36,46 @@ function loginUser(){
loginInProgress.value = true; loginInProgress.value = true;
authService.loginUser( loginDto ) authService.loginUser( loginDto )
.then( ( response ) => { .then( ( response ) => {
userStore.setUser(response as User); userStore.loginUser( response );
router.push({ name: 'profile'}); if( route.query.r ){
}) router.push( { name: route.query.r.toString() } );
.catch( ( err ) => {
console.error(err);
const modalText = t('login.error.process');
if( showInfoModal !== undefined ){
showInfoModal(t('common.error.generic'), modalText);
} else { } else {
alert(modalText); router.push( { name: 'profile' } );
} }
}) } )
.catch( ( err: Error | AxiosError ) => {
console.error( err );
if( err instanceof AxiosError && err.response?.data === 4011 ){
showErrorMessage(t('login.error.credentials'));
} else {
const modalText = t( 'login.error.process' );
if( showInfoModal !== undefined ) {
showInfoModal( t( 'common.error.generic' ), modalText );
} else {
alert( modalText );
}
}
} )
.finally( () => { .finally( () => {
loginInProgress.value = false; loginInProgress.value = false;
}) } );
} }
const withI18nMessage = createI18nMessage({t: t}); function showErrorMessage(messageText: string, msTimeShown: number = 3000): void {
const inputRequired = withI18nMessage(required); errorMessage.value = messageText;
const rules = computed( () => ({ setTimeout( () => {
errorMessage.value = '';
}, msTimeShown );
}
const withI18nMessage = createI18nMessage( { t: t } );
const inputRequired = withI18nMessage( required );
const rules = computed( () => ( {
username: { inputRequired }, username: { inputRequired },
password: { inputRequired }, password: { inputRequired },
})); } ) );
const v$ = useVuelidate(rules, { username, password }); const v$ = useVuelidate( rules, { username, password } );
</script> </script>
@ -81,19 +94,21 @@ const v$ = useVuelidate(rules, { username, password });
<div class="mb-3"> <div class="mb-3">
<label for="input-username"> <label for="input-username">
{{ t( 'login.username' ) }} {{ t( 'login.username' ) }}
<span v-if="v$.username.$error" class="text-danger ps-3"> <span v-if=" v$.username.$error " class="text-danger ps-3">
{{ v$.username.$errors[0].$message }} {{ v$.username.$errors[0].$message }}
</span></label> </span></label>
<input v-model="username" type="text" id="input-username" class="form-control" :class="[{'border-danger': v$.username.$error}]" @blur="v$.username.$touch"> <input v-model="username" type="text" id="input-username" class="form-control"
:class="[{ 'border-danger': v$.username.$error }]" @blur="v$.username.$touch" @keyup.enter="loginUser">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="input-username"> <label for="input-username">
{{ t( 'login.password' ) }} {{ t( 'login.password' ) }}
<span v-if="v$.password.$error" class="text-danger ps-3"> <span v-if=" v$.password.$error " class="text-danger ps-3">
{{ v$.password.$errors[0].$message }} {{ v$.password.$errors[0].$message }}
</span> </span>
</label> </label>
<input v-model="password" type="password" id="input-username" class="form-control" :class="[{'border-danger': v$.password.$error}]" @blur="v$.password.$touch"> <input v-model="password" type="password" id="input-username" class="form-control"
:class="[{ 'border-danger': v$.password.$error }]" @blur="v$.password.$touch" @keyup.enter="loginUser">
</div> </div>
<div class="mb-3 d-flex justify-content-between"> <div class="mb-3 d-flex justify-content-between">
<RouterLink to="/signup" class="btn btn-outline-primary"> <RouterLink to="/signup" class="btn btn-outline-primary">
@ -103,7 +118,7 @@ const v$ = useVuelidate(rules, { username, password });
{{ t( "login.loginButton" ) }} {{ t( "login.loginButton" ) }}
</button> </button>
</div> </div>
<div v-if="errorMessage" class="alert alert-danger" role="alert">{{ errorMessage }}</div> <div v-if=" errorMessage " class="alert alert-danger" role="alert">{{ errorMessage }}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,34 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { useUserStore } from '@/stores/UserStore';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/stores/UserStore';
import BoardSelector from '@/components/blocks/BoardSelector.vue'; import BoardSelector from '@/components/blocks/BoardSelector.vue';
import EditProfileModal from '@/components/modals/EditProfileModal.vue';
const { t } = useI18n(); const { t } = useI18n();
const userStore = useUserStore(); const userStore = useUserStore();
const pfpSource = computed( () => { const editProfileModalId = "edit-profile-modal-on-profile-page";
return ( userStore.profilePicture === null ? "/src/assets/images/PFP_BearHead.svg" : userStore.profilePicture )
})
</script> </script>
<template> <template>
<div class="d-flex flex-column justify-content-center align-items-center mt-5"> <div class="d-flex flex-column justify-content-center align-items-center mt-5">
<div class="row mb-3">
<div class="col d-flex justify-content-center align-items-center flex-column">
<h1> <h1>
{{ $t('profile.yourProfile') }} {{ t('profile.yourProfile') }}
</h1> </h1>
<div class="ratio ratio-1x1 border rounded-5" style="width: 15rem;"> <div class="ratio ratio-1x1 border rounded-5" style="width: 15rem;">
<img :src="pfpSource" alt="Your Profile Picture" /> <img :src="userStore.pfpSource" alt="Your Profile Pic" />
</div> </div>
<p class="fs-3"> <p class="fs-3">
{{ userStore.username }} {{ userStore.username }}
</p> </p>
<div class="d-flex gap-3">
<button class="btn btn-outline-primary" :data-bs-target="`#${editProfileModalId}`" data-bs-toggle="modal">
Edit Profile
</button>
</div>
</div>
</div>
<div class="row bg-body-secondary w-100 py-4"> <div class="row bg-body-secondary w-100 py-4">
<div class="col text-center"> <div class="col text-center">
<h2> <h2>
{{ $t('profile.yourBoards') }} {{ t('profile.yourBoards') }}
</h2> </h2>
<div class="container"> <div class="container">
<BoardSelector /> <BoardSelector />
@ -38,10 +47,11 @@ const pfpSource = computed( () => {
<div class="row w-100 py-4 mb-5"> <div class="row w-100 py-4 mb-5">
<div class="col text-center"> <div class="col text-center">
<h2> <h2>
{{ $t('settings.heading') }} {{ t('settings.heading') }}
</h2> </h2>
<button class="btn btn-outline-primary">{{ $t('profile.gotoSettings') }}</button> <button class="btn btn-outline-primary">{{ t('profile.gotoSettings') }}</button>
</div> </div>
</div> </div>
</div> </div>
<EditProfileModal :modalId="editProfileModalId"/>
</template> </template>

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type LoginDto } from '@/models/dto/LoginDto'; import { type LoginDto } from '@/models/dto/LoginDto';
import type { User } from '@/models/user/User';
import { authService } from '@/services/AuthService'; import { authService } from '@/services/AuthService';
import { infoModalShowFnKey } from '@/services/UtilService'; import { infoModalShowFnKey } from '@/services/UtilService';
import { useUserStore } from '@/stores/UserStore'; import { useUserStore } from '@/stores/UserStore';
@ -15,22 +14,22 @@ const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const showInfoModal = inject(infoModalShowFnKey); const showInfoModal = inject( infoModalShowFnKey );
const username = ref(''); const username = ref( '' );
const password = ref(''); const password = ref( '' );
const passwordRepeat = ref(''); const passwordRepeat = ref( '' );
const errorMessage = ref(''); const errorMessage = ref( '' );
const signupInProgress = ref(false); const signupInProgress = ref( false );
function signupUser(){ function signupUser() {
v$.value.$touch(); v$.value.$touch();
if(v$.value.$error){ if( v$.value.$error ) {
errorMessage.value = t('forms.validate-fields'); errorMessage.value = t( 'forms.validate-fields' );
setTimeout(() => { setTimeout( () => {
errorMessage.value = ''; errorMessage.value = '';
}, 3000); }, 3000 );
return; return;
} else { } else {
errorMessage.value = ''; errorMessage.value = '';
@ -39,32 +38,32 @@ function signupUser(){
signupInProgress.value = true; signupInProgress.value = true;
authService.signupUser( signupDto ) authService.signupUser( signupDto )
.then( ( response ) => { .then( ( response ) => {
userStore.setUser(response as User); userStore.loginUser( response );
router.push({ name: 'profile'}); router.push( { name: 'profile' } );
}) } )
.catch( ( err ) => { .catch( ( err ) => {
console.error(err); console.error( err );
const modalText = t('signup.error.process'); const modalText = t( 'login.error.process' );
if( showInfoModal !== undefined ){ if( showInfoModal !== undefined ) {
showInfoModal(t('common.error.generic'), modalText); showInfoModal( t( 'common.error.generic' ), modalText );
} else { } else {
alert(modalText); alert( modalText );
} }
}) } )
.finally( () => { .finally( () => {
signupInProgress.value = false; signupInProgress.value = false;
}) } );
} }
const withI18nMessage = createI18nMessage({t: t}); const withI18nMessage = createI18nMessage( { t: t } );
const inputRequired = withI18nMessage(required); const inputRequired = withI18nMessage( required );
const rules = computed( () => ({ const rules = computed( () => ( {
username: { inputRequired }, username: { inputRequired },
password: { inputRequired, minLength: withI18nMessage(minLength(10)) }, password: { inputRequired, minLength: withI18nMessage( minLength( 10 ) ) },
passwordRepeat: { inputRequired, sameAs: withI18nMessage(sameAs(password.value, t('login.password'))) }, passwordRepeat: { inputRequired, sameAs: withI18nMessage( sameAs( password.value, t( 'login.password' ) ) ) },
})); } ) );
const v$ = useVuelidate(rules, { username, password, passwordRepeat }); const v$ = useVuelidate( rules, { username, password, passwordRepeat } );
</script> </script>
@ -83,40 +82,44 @@ const v$ = useVuelidate(rules, { username, password, passwordRepeat });
<div class="mb-3"> <div class="mb-3">
<label for="input-username"> <label for="input-username">
{{ t( 'login.username' ) }} {{ t( 'login.username' ) }}
<span v-if="v$.username.$error" class="text-danger ps-3"> <span v-if=" v$.username.$error " class="text-danger ps-3">
{{ v$.username.$errors[0].$message }} {{ v$.username.$errors[0].$message }}
</span> </span>
</label> </label>
<input type="text" id="input-username" class="form-control" :class="[{'border-danger': v$.username.$error}]" v-model="username" @blur="v$.username.$touch"> <input type="text" id="input-username" class="form-control"
:class="[{ 'border-danger': v$.username.$error }]" v-model="username" @blur="v$.username.$touch" @keyup.enter="signupUser">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="input-username"> <label for="input-username">
{{ t( 'login.password' ) }} {{ t( 'login.password' ) }}
<span v-if="v$.password.$error" class="text-danger ps-3"> <span v-if=" v$.password.$error " class="text-danger ps-3">
{{ v$.password.$errors[0].$message }} {{ v$.password.$errors[0].$message }}
</span> </span>
</label> </label>
<input type="password" id="input-username" class="form-control" :class="[{'border-danger': v$.password.$error}]" v-model="password" @blur="v$.password.$touch"> <input type="password" id="input-username" class="form-control"
:class="[{ 'border-danger': v$.password.$error }]" v-model="password" @blur="v$.password.$touch" @keyup.enter="signupUser">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="input-username-repeat"> <label for="input-username-repeat">
{{ t( 'signup.password-repeat' ) }} {{ t( 'signup.password-repeat' ) }}
<span v-if="v$.passwordRepeat.$error" class="text-danger ps-3"> <span v-if=" v$.passwordRepeat.$error " class="text-danger ps-3">
{{ v$.passwordRepeat.$errors[0].$message }} {{ v$.passwordRepeat.$errors[0].$message }}
</span> </span>
</label> </label>
<input type="password" id="input-username-repeat" class="form-control" :class="[{'border-danger': v$.passwordRepeat.$error}]" v-model="passwordRepeat" @blur="v$.passwordRepeat.$touch"> <input type="password" id="input-username-repeat" class="form-control"
:class="[{ 'border-danger': v$.passwordRepeat.$error }]" v-model="passwordRepeat"
@blur="v$.passwordRepeat.$touch" @keyup.enter="signupUser">
</div> </div>
<div class="mb-3 d-flex justify-content-between"> <div class="mb-3 d-flex justify-content-between">
<RouterLink to="/login" class="btn btn-outline-primary"> <RouterLink to="/login" class="btn btn-outline-primary">
{{ t( "signup.loginLinkButton" ) }} {{ t( "signup.loginLinkButton" ) }}
</RouterLink> </RouterLink>
<button class="btn btn-primary" @click="signupUser" :disabled="signupInProgress"> <button class="btn btn-primary" @click="signupUser" :disabled="signupInProgress">
<font-awesome-icon v-if="signupInProgress" :icon="['fas', 'spinner']" spin/> <FontAwesomeIcon v-if=" signupInProgress " :icon="['fas', 'spinner']" spin />
{{ t( "signup.signupButton" ) }} {{ t( "signup.signupButton" ) }}
</button> </button>
</div> </div>
<div v-if="errorMessage" class="alert alert-danger" role="alert">{{ errorMessage }}</div> <div v-if=" errorMessage " class="alert alert-danger" role="alert">{{ errorMessage }}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -12,7 +12,8 @@
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"error": { "error": {
"process": "An error occured during the login process" "process": "An error occured during the login process",
"credentials": "Username or password incorrect"
} }
}, },
"signup": { "signup": {
@ -37,14 +38,19 @@
"profile": { "profile": {
"yourProfile": "Your Profile", "yourProfile": "Your Profile",
"yourBoards": "Your Boards", "yourBoards": "Your Boards",
"gotoSettings": "Go to Settings" "gotoSettings": "Go to Settings",
"edit": {
"title": "Edit your Profile"
}
}, },
"settings": { "settings": {
"heading": "Settings" "heading": "Settings"
}, },
"common": { "common": {
"buttons": { "buttons": {
"close": "Close" "close": "Close",
"save": "Save",
"saveAndExit": "Save and Exit"
}, },
"error": { "error": {
"generic": "Error" "generic": "Error"

View File

@ -7,13 +7,9 @@ import router from './router';
import '@/assets/scss/customized_bootstrap.scss'; import '@/assets/scss/customized_bootstrap.scss';
// Importing bootstrap components which rely on js here
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Dropdown } from 'bootstrap';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { library } from '@fortawesome/fontawesome-svg-core'; import { library } from '@fortawesome/fontawesome-svg-core';
import { faSun, faMoon, faCircleHalfStroke, faEdit, faPlay, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { faSun, faMoon, faCircleHalfStroke, faEdit, faPlay, faSpinner, faLanguage, faGlobe } from '@fortawesome/free-solid-svg-icons';
import enMessages from './locales/en.json'; import enMessages from './locales/en.json';
import deMessages from './locales/de.json'; import deMessages from './locales/de.json';
@ -34,7 +30,9 @@ library.add(
faCircleHalfStroke, faCircleHalfStroke,
faEdit, faEdit,
faPlay, faPlay,
faSpinner faSpinner,
faLanguage,
faGlobe,
) )
const app = createApp( App ); const app = createApp( App );

View File

@ -1,14 +1,4 @@
// export class LoginDto{
// username: String;
// password: String;
// constructor(username: String, password: String){
// this.username = username;
// this.password = password;
// }
// }
export type LoginDto = { export type LoginDto = {
username: String; username: string;
password: String; password: string;
} }

View File

@ -1,5 +1,4 @@
export type User = { export type User = {
username: string, username: string,
password: string,
profilePictureFilename: string | undefined, profilePictureFilename: string | undefined,
} }

View File

@ -0,0 +1,14 @@
// This can be directly added to any of your `.ts` files like `router.ts`
// It can also be added to a `.d.ts` file. Make sure it's included in
// project's tsconfig.json "files"
import 'vue-router'
// To ensure it is treated as a module, add at least one `export` statement
export {}
declare module 'vue-router' {
interface RouteMeta {
// must be declared by every route
requiresAuth: boolean
}
}

View File

@ -5,6 +5,7 @@ import LoginPage from '@/components/pages/LoginPage.vue';
import SignupPage from '@/components/pages/SignupPage.vue'; import SignupPage from '@/components/pages/SignupPage.vue';
import GamePage from '@/components/pages/GamePage.vue'; import GamePage from '@/components/pages/GamePage.vue';
import ProfilePage from '@/components/pages/ProfilePage.vue'; import ProfilePage from '@/components/pages/ProfilePage.vue';
import { useUserStore } from '@/stores/UserStore';
const router = createRouter( { const router = createRouter( {
history: createWebHistory( import.meta.env.BASE_URL ), history: createWebHistory( import.meta.env.BASE_URL ),
@ -13,31 +14,57 @@ const router = createRouter( {
path: '/', path: '/',
name: 'home', name: 'home',
component: HomePage, component: HomePage,
meta: {
requiresAuth: false,
}
}, },
{ {
path: '/about', path: '/about',
name: 'about', name: 'about',
component: AboutPage, component: AboutPage,
meta: {
requiresAuth: false,
}
}, },
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: LoginPage, component: LoginPage,
meta: {
requiresAuth: false,
}
}, },
{ {
path: '/signup', path: '/signup',
name: 'signup', name: 'signup',
component: SignupPage, component: SignupPage,
meta: {
requiresAuth: false,
}
}, },
{ {
path: '/profile', path: '/profile',
name: 'profile', name: 'profile',
component: ProfilePage, component: ProfilePage,
meta: {
requiresAuth: true,
}
}, },
{ {
path: '/game', path: '/game',
name: 'Game', name: 'game',
component: GamePage, component: GamePage,
meta: {
requiresAuth: false,
}
},
{
path: '/board',
name: 'board',
component: ProfilePage,
meta: {
requiresAuth: true,
}
}, },
// { // {
// path: '/about', // path: '/about',
@ -47,7 +74,23 @@ const router = createRouter( {
// // which is lazy-loaded when the route is visited. // // which is lazy-loaded when the route is visited.
// component: () => import('../views/AboutView.vue') // component: () => import('../views/AboutView.vue')
// } // }
] ],
} );
router.beforeEach( ( to, from, next ) => {
const userStore = useUserStore();
userStore.userCheckPromise
.finally( () => {
if( to.meta.requiresAuth === true && !userStore.loggedIn ) {
if( from.name === 'login' ) {
console.error( 'recursive forward detected' );
next( { name: 'home' } );
}
next( { name: 'login', query: { r: to.name?.toString() } } );
} else {
next();
}
} );
} ); } );
export default router; export default router;

View File

@ -1,11 +1,11 @@
import { ENV } from "@/Env"; import { ENV } from "@/Env";
import type { LoginDto } from '@/models/dto/LoginDto'; import type { LoginDto } from '@/models/dto/LoginDto';
import axios from "axios"; import type { User } from '@/models/user/User';
import axios, { AxiosError } from "axios";
class AuthService { class AuthService {
signupUser( signupDto: LoginDto ) { signupUser( signupDto: LoginDto ): Promise<User> {
return new Promise( ( resolve, reject ) => { return new Promise( ( resolve, reject ) => {
axios.post( `${ENV.API_BASE_URL}/auth/signup`, axios.post( `${ENV.API_BASE_URL}/auth/signup`,
signupDto, signupDto,
@ -16,13 +16,13 @@ class AuthService {
.then( ( response ) => { .then( ( response ) => {
resolve( response.data ); resolve( response.data );
} ) } )
.catch( ( error ) => { .catch( ( error: Error | AxiosError ) => {
reject( error ); reject( error );
} ); } );
} ); } );
} }
loginUser( loginDto: LoginDto ) { loginUser( loginDto: LoginDto ): Promise<User> {
return new Promise( ( resolve, reject ) => { return new Promise( ( resolve, reject ) => {
axios.post( `${ENV.API_BASE_URL}/auth/login`, axios.post( `${ENV.API_BASE_URL}/auth/login`,
loginDto, loginDto,
@ -31,15 +31,15 @@ class AuthService {
}, },
) )
.then( ( response ) => { .then( ( response ) => {
resolve( response ); resolve( response.data );
} ) } )
.catch( ( error ) => { .catch( ( error: Error | AxiosError ) => {
reject( error ); reject( error );
} ); } );
} ); } );
} }
logoutUser() { logoutUser(): Promise<string> {
return new Promise( ( resolve, reject ) => { return new Promise( ( resolve, reject ) => {
axios.post( `${ENV.API_BASE_URL}/auth/logout`, axios.post( `${ENV.API_BASE_URL}/auth/logout`,
null, null,
@ -48,9 +48,25 @@ class AuthService {
}, },
) )
.then( ( response ) => { .then( ( response ) => {
resolve( response ); resolve( response.data );
} ) } )
.catch( ( error ) => { .catch( ( error: Error | AxiosError ) => {
reject( error );
} );
} );
}
checkUser(): Promise<User> {
return new Promise( ( resolve, reject ) => {
axios.get( `${ENV.API_BASE_URL}/auth/status`,
{
withCredentials: true,
},
)
.then( ( response ) => {
resolve( response.data );
} )
.catch( ( error: Error | AxiosError ) => {
reject( error ); reject( error );
} ); } );
} ); } );

View File

@ -1,8 +1,3 @@
import type { InjectionKey } from 'vue'; import type { InjectionKey } from 'vue';
class UtilService{
}
export const infoModalShowFnKey = Symbol() as InjectionKey<Function>; export const infoModalShowFnKey = Symbol() as InjectionKey<Function>;

View File

@ -1,38 +1,59 @@
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { User } from '@/models/user/User'; import type { User } from '@/models/user/User';
import { authService } from '@/services/AuthService';
import { AxiosError } from 'axios';
export const useUserStore = defineStore( 'user', () => { export const useUserStore = defineStore( 'user', () => {
const username = ref( '' ); const username = ref( '' );
const profilePicture = ref<undefined | string>( undefined ); const profilePicture = ref<null | string>( null );
const loggedIn = ref(false); const loggedIn = ref( false );
const getUserOutput = computed(() => `${username.value}`); const getUserOutput = computed( () => `${username.value}` );
const pfpSource = computed( () => {
return profilePicture.value ?? "/src/assets/images/PFP_BearHead.svg"
})
function setUser(user: User){ function loginUser( user: User ) {
username.value = user.username; username.value = user.username;
profilePicture.value = user.profilePictureFilename; profilePicture.value = user.profilePictureFilename ?? null;
loggedIn.value = true; loggedIn.value = true;
sessionStorage.setItem() //???
} }
function unsetUser(){ function logoutUser() {
username.value = ''; username.value = '';
profilePicture.value = undefined; profilePicture.value = null;
loggedIn.value = false; loggedIn.value = false;
} }
function getCheckUser(): Promise<User> {
return new Promise( ( resolve, reject ) => {
authService.checkUser()
.then( ( user ) => {
loginUser( user );
resolve( user );
} )
.catch( ( error: Error | AxiosError ) => {
console.debug( error );
reject( error );
} );
} );
}
const userCheckPromise = getCheckUser();
return { return {
//Refs //Refs
username, username,
profilePicture, profilePicture,
loggedIn, loggedIn,
userCheckPromise,
//Getters //Getters
getUserOutput, getUserOutput,
pfpSource,
//Functions //Functions
setUser, loginUser,
unsetUser, logoutUser,
}; };
} ); } );