diff --git a/backend/spring-boot/docker-compose-api.dev.yml b/backend/spring-boot/docker-compose-api.dev.yml index 88c8ff66..eb59f45f 100644 --- a/backend/spring-boot/docker-compose-api.dev.yml +++ b/backend/spring-boot/docker-compose-api.dev.yml @@ -21,6 +21,8 @@ services: REDIS_DATABASE: ${REDIS_DATABASE:-0} REDIS_PASSWORD: ${REDIS_PASSWORD:-root} USER_PASSWORD: ${USER_PASSWORD:-qwerty123} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:client_id} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:client_secret} SMTP_HOST: ${SMTP_HOST:-smtp.sendgrid.net} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USERNAME: ${SMTP_USER:-apikey} diff --git a/backend/spring-boot/pom.xml b/backend/spring-boot/pom.xml index 75e014d0..a97a563e 100644 --- a/backend/spring-boot/pom.xml +++ b/backend/spring-boot/pom.xml @@ -37,6 +37,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-oauth2-client + org.springframework.boot spring-boot-starter-actuator diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2FailureHandler.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2FailureHandler.java new file mode 100644 index 00000000..d304afeb --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2FailureHandler.java @@ -0,0 +1,56 @@ +package org.bugzkit.api.auth.oauth2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.bugzkit.api.shared.error.ErrorMessage; +import org.bugzkit.api.shared.logger.CustomLogger; +import org.bugzkit.api.shared.message.service.MessageService; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@Component +public class OAuth2FailureHandler implements AuthenticationFailureHandler { + private final MessageService messageService; + private final CustomLogger customLogger; + + @Value("${ui.url}") + private String uiUrl; + + public OAuth2FailureHandler(MessageService messageService, CustomLogger customLogger) { + this.messageService = messageService; + this.customLogger = customLogger; + } + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException { + final var errorMessage = new ErrorMessage(HttpStatus.UNAUTHORIZED); + errorMessage.addCode(getCode(exception)); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(errorMessage.toString()); + response.sendRedirect( + uiUrl + + "/auth/sign-in?error=" + + URLEncoder.encode(errorMessage.getCodes().getFirst(), StandardCharsets.UTF_8)); + customLogger.error("OAuth failed", exception); + MDC.remove("REQUEST_ID"); + } + + private String getCode(AuthenticationException exception) { + try { + return messageService.getMessage(exception.getMessage()); + } catch (Exception e) { + return messageService.getMessage("server.internalError"); + } + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java new file mode 100644 index 00000000..a253eded --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2SuccessHandler.java @@ -0,0 +1,65 @@ +package org.bugzkit.api.auth.oauth2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.stream.Collectors; +import org.bugzkit.api.auth.jwt.service.AccessTokenService; +import org.bugzkit.api.auth.jwt.service.RefreshTokenService; +import org.bugzkit.api.auth.util.AuthUtil; +import org.bugzkit.api.shared.logger.CustomLogger; +import org.bugzkit.api.user.payload.dto.RoleDTO; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + private final AccessTokenService accessTokenService; + private final RefreshTokenService refreshTokenService; + private final CustomLogger customLogger; + + @Value("${ui.url}") + private String uiUrl; + + @Value("${jwt.access-token.duration}") + private int accessTokenDuration; + + @Value("${jwt.refresh-token.duration}") + private int refreshTokenDuration; + + public OAuth2SuccessHandler( + AccessTokenService accessTokenService, + RefreshTokenService refreshTokenService, + CustomLogger customLogger) { + this.accessTokenService = accessTokenService; + this.refreshTokenService = refreshTokenService; + this.customLogger = customLogger; + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException { + final var userPrincipal = (OAuth2UserPrincipal) authentication.getPrincipal(); + final var roleDTOs = + userPrincipal.getAuthorities().stream() + .map(authority -> new RoleDTO(authority.getAuthority())) + .collect(Collectors.toSet()); + final var ipAddress = AuthUtil.getUserIpAddress(request); + final var accessToken = accessTokenService.create(userPrincipal.getId(), roleDTOs); + final var refreshToken = refreshTokenService.create(userPrincipal.getId(), roleDTOs, ipAddress); + final var accessTokenCookie = + AuthUtil.createCookie("accessToken", accessToken, accessTokenDuration); + final var refreshTokenCookie = + AuthUtil.createCookie("refreshToken", refreshToken, refreshTokenDuration); + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + response.sendRedirect(uiUrl); + customLogger.info("Finished"); + MDC.remove("REQUEST_ID"); + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2UserPrincipal.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2UserPrincipal.java new file mode 100644 index 00000000..97ac3e85 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2UserPrincipal.java @@ -0,0 +1,32 @@ +package org.bugzkit.api.auth.oauth2; + +import java.util.Map; +import org.bugzkit.api.auth.security.UserPrincipal; +import org.springframework.security.oauth2.core.user.OAuth2User; + +public class OAuth2UserPrincipal extends UserPrincipal implements OAuth2User { + private final Map attributes; + + public OAuth2UserPrincipal(UserPrincipal principal, Map attributes) { + super( + principal.getId(), + principal.getUsername(), + principal.getEmail(), + principal.getPassword(), + principal.isEnabled(), + principal.isAccountNonLocked(), + principal.getCreatedAt(), + principal.getAuthorities()); + this.attributes = attributes; + } + + @Override + public String getName() { + return this.getEmail(); + } + + @Override + public Map getAttributes() { + return this.attributes; + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2UserService.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2UserService.java new file mode 100644 index 00000000..fc958604 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/oauth2/OAuth2UserService.java @@ -0,0 +1,55 @@ +package org.bugzkit.api.auth.oauth2; + +import java.util.Collections; +import java.util.UUID; +import org.bugzkit.api.auth.security.UserPrincipal; +import org.bugzkit.api.shared.logger.CustomLogger; +import org.bugzkit.api.user.model.Role.RoleName; +import org.bugzkit.api.user.model.User; +import org.bugzkit.api.user.repository.RoleRepository; +import org.bugzkit.api.user.repository.UserRepository; +import org.slf4j.MDC; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +public class OAuth2UserService extends DefaultOAuth2UserService { + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final CustomLogger customLogger; + + public OAuth2UserService( + UserRepository userRepository, RoleRepository roleRepository, CustomLogger customLogger) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + this.customLogger = customLogger; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + final var requestId = UUID.randomUUID().toString(); + MDC.put("REQUEST_ID", requestId); + customLogger.info("Called"); + final var oAuthUser = super.loadUser(userRequest); + final String email = oAuthUser.getAttribute("email"); + final boolean emailVerified = Boolean.TRUE.equals(oAuthUser.getAttribute("email_verified")); + if (!emailVerified) throw new DisabledException("user.notActive"); + final var user = userRepository.findWithRolesByEmail(email).orElseGet(() -> createUser(email)); + if (user.getUsername() == null) user.setUsername(email); + return new OAuth2UserPrincipal(UserPrincipal.create(user), oAuthUser.getAttributes()); + } + + private User createUser(String email) { + final var roles = + roleRepository + .findByName(RoleName.USER) + .orElseThrow(() -> new RuntimeException("user.roleNotFound")); + final var user = + User.builder().email(email).active(true).roles(Collections.singleton(roles)).build(); + return userRepository.save(user); + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/security/UserDetailsServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/security/UserDetailsServiceImpl.java index e96638e6..d6d1882c 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/security/UserDetailsServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/security/UserDetailsServiceImpl.java @@ -1,6 +1,6 @@ package org.bugzkit.api.auth.security; -import org.bugzkit.api.shared.error.exception.UnauthorizedException; +import org.bugzkit.api.shared.error.exception.ResourceNotFoundException; import org.bugzkit.api.user.repository.UserRepository; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -20,7 +20,7 @@ public UserDetails loadUserByUserId(Long userId) { return userRepository .findById(userId) .map(UserPrincipal::create) - .orElseThrow(() -> new UnauthorizedException("auth.unauthorized")); + .orElseThrow(() -> new ResourceNotFoundException("user.notFound")); } @Override @@ -29,6 +29,6 @@ public UserDetails loadUserByUsername(String username) { return userRepository .findByUsernameOrEmail(username, username) .map(UserPrincipal::create) - .orElseThrow(() -> new UnauthorizedException("auth.unauthorized")); + .orElseThrow(() -> new ResourceNotFoundException("user.notFound")); } } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java index 13380f1b..338e5fe7 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/util/AuthUtil.java @@ -57,7 +57,7 @@ public static ResponseCookie createCookie(String name, String value, int maxAge) .secure(true) .path("/") .maxAge(maxAge) - .sameSite(SameSite.STRICT.attributeValue()) + .sameSite(SameSite.LAX.attributeValue()) .build(); } } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java index fb12ab25..5b67c8a9 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java @@ -1,5 +1,8 @@ package org.bugzkit.api.shared.config; +import org.bugzkit.api.auth.oauth2.OAuth2FailureHandler; +import org.bugzkit.api.auth.oauth2.OAuth2SuccessHandler; +import org.bugzkit.api.auth.oauth2.OAuth2UserService; import org.bugzkit.api.auth.security.JWTFilter; import org.bugzkit.api.auth.security.UserDetailsServiceImpl; import org.bugzkit.api.shared.constants.Path; @@ -23,6 +26,7 @@ @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { + private static final String[] OAUTH2_WHITELIST = {"/oauth2/authorization/google"}; private static final String[] AUTH_WHITELIST = { Path.AUTH + "/register", Path.AUTH + "/tokens", @@ -41,14 +45,23 @@ public class SecurityConfig { private final JWTFilter jwtFilter; private final UserDetailsServiceImpl userDetailsService; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + private final OAuth2UserService oAuth2UserService; public SecurityConfig( JWTFilter jwtFilter, UserDetailsServiceImpl userDetailsService, - CustomAuthenticationEntryPoint customAuthenticationEntryPoint) { + CustomAuthenticationEntryPoint customAuthenticationEntryPoint, + OAuth2SuccessHandler oAuth2SuccessHandler, + OAuth2FailureHandler oAuth2FailureHandler, + OAuth2UserService oAuth2UserService) { this.jwtFilter = jwtFilter; this.userDetailsService = userDetailsService; this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; + this.oAuth2SuccessHandler = oAuth2SuccessHandler; + this.oAuth2FailureHandler = oAuth2FailureHandler; + this.oAuth2UserService = oAuth2UserService; } @Bean @@ -77,8 +90,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers(ACTUATOR_WHITELIST) .permitAll() + .requestMatchers(OAUTH2_WHITELIST) + .permitAll() .anyRequest() .authenticated()) + .oauth2Login( + oauth -> + oauth + .userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler)) .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterAfter(jwtFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling( diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/model/User.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/model/User.java index bb1cfafd..5231f47f 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/model/User.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/model/User.java @@ -43,13 +43,12 @@ public class User implements Serializable { @Column(name = "user_id") private Long id; - @Column(unique = true, nullable = false) + @Column(unique = true) private String username; @Column(unique = true, nullable = false) private String email; - @Column(nullable = false) private String password; @Builder.Default diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/repository/UserRepository.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/repository/UserRepository.java index 568b05e0..83a8588e 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/repository/UserRepository.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/repository/UserRepository.java @@ -23,6 +23,9 @@ public interface UserRepository extends JpaRepository { @EntityGraph(attributePaths = "roles") Optional findWithRolesByUsername(String username); + @EntityGraph(attributePaths = "roles") + Optional findWithRolesByEmail(String email); + Optional findByEmail(String email); Optional findByUsernameOrEmail(String username, String email); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/service/impl/ProfileServiceImpl.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/service/impl/ProfileServiceImpl.java index df5b050e..e9d9f0d7 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/service/impl/ProfileServiceImpl.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/service/impl/ProfileServiceImpl.java @@ -63,7 +63,7 @@ private void deleteAuthTokens(Long userId) { } private void setUsername(User user, String username) { - if (user.getUsername().equals(username)) return; + if (user.getUsername() != null && user.getUsername().equals(username)) return; if (userRepository.existsByUsername(username)) throw new ConflictException("user.usernameExists"); diff --git a/backend/spring-boot/src/main/resources/application-dev.yml b/backend/spring-boot/src/main/resources/application-dev.yml index a89c4e87..3213a36e 100644 --- a/backend/spring-boot/src/main/resources/application-dev.yml +++ b/backend/spring-boot/src/main/resources/application-dev.yml @@ -15,6 +15,13 @@ spring: security: user: password: ${USER_PASSWORD:qwerty123} + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID:client_id} + client-secret: ${GOOGLE_CLIENT_SECRET:client_secret} + scope: profile,email mail: password: ${SMTP_PASSWORD:password} diff --git a/backend/spring-boot/src/main/resources/application-prod.yml b/backend/spring-boot/src/main/resources/application-prod.yml index 7e2e5b41..bfb9c021 100644 --- a/backend/spring-boot/src/main/resources/application-prod.yml +++ b/backend/spring-boot/src/main/resources/application-prod.yml @@ -17,6 +17,13 @@ spring: security: user: password: ${user_password} # remove when liquibase is added + oauth2: + client: + registration: + google: + client-id: ${google_client_id} + client-secret: ${google_client_secret} + scope: profile,email mail: password: ${smtp_password} diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/user/data/UserRepositoryIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/user/data/UserRepositoryIT.java index cc76cc8a..db7873fb 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/user/data/UserRepositoryIT.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/user/data/UserRepositoryIT.java @@ -80,6 +80,13 @@ void findByUsernameWithRoles() { assertThat(user.getRoles()).hasSize(1); } + @Test + void findByEmailWithRoles() { + final var user = userRepository.findWithRolesByEmail("user@localhost").orElseThrow(); + assertThat(user.getUsername()).isEqualTo("user"); + assertThat(user.getRoles()).hasSize(1); + } + @Test void findByEmail() { assertThat(userRepository.findByEmail("admin@localhost")).isPresent(); diff --git a/backend/spring-boot/src/test/resources/application.yml b/backend/spring-boot/src/test/resources/application.yml index e64fff42..099cca95 100644 --- a/backend/spring-boot/src/test/resources/application.yml +++ b/backend/spring-boot/src/test/resources/application.yml @@ -39,6 +39,13 @@ spring: security: user: password: ${USER_PASSWORD:qwerty123} + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID:client_id} + client-secret: ${GOOGLE_CLIENT_SECRET:client_secret} + scope: profile,email mail: host: ${SMTP_HOST:smtp.sendgrid.net} port: ${SMTP_PORT:587} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index df0b194c..5d93be21 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,6 +22,8 @@ services: REDIS_PORT: ${REDIS_PORT:-6379} REDIS_DATABASE: ${REDIS_DATABASE:-0} REDIS_PASSWORD: ${REDIS_PASSWORD:-root} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:client_id} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:client_secret} USER_PASSWORD: ${USER_PASSWORD:-qwerty123} SMTP_HOST: ${SMTP_HOST:-smtp.sendgrid.net} SMTP_PORT: ${SMTP_PORT:-587} @@ -128,7 +130,7 @@ services: depends_on: - bugzkit-api healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:${UI_PORT:-5173}"] + test: [ "CMD", "curl", "-f", "http://localhost:${UI_PORT:-5173}" ] start_period: 10s interval: 30s retries: 3 diff --git a/docker-stack.prod.yml b/docker-stack.prod.yml index 5291f6e9..4aaf2aac 100644 --- a/docker-stack.prod.yml +++ b/docker-stack.prod.yml @@ -27,6 +27,8 @@ services: - user_password - smtp_password - jwt_secret + - google_client_id + - google_client_secret depends_on: - postgres - redis @@ -199,3 +201,7 @@ secrets: external: true user_password: external: true + google_client_id: + external: true + google_client_secret: + external: true diff --git a/docs/src/content/environment-variables.mdx b/docs/src/content/environment-variables.mdx index 512d3d18..3160dee7 100644 --- a/docs/src/content/environment-variables.mdx +++ b/docs/src/content/environment-variables.mdx @@ -15,6 +15,8 @@ | REDIS_PORT | Redis port | 6379 | | REDIS_DATABASE | Redis database index | 0 | | REDIS_PASSWORD | Redis password | root | +| GOOGLE_CLIENT_ID | Google client id used by oauth2 | client_id | +| GOOGLE_CLIENT_SECRET | Google client secret used by oauth2 | client_secret | | SMTP_HOST | SMTP server host | smtp.sendgrid.net | | SMTP_PORT | SMTP server port | 587 | | SMTP_USER | SMTP user | apikey | diff --git a/frontend/svelte-kit/messages/en.json b/frontend/svelte-kit/messages/en.json index f6b2d1a6..0d3e56d7 100644 --- a/frontend/svelte-kit/messages/en.json +++ b/frontend/svelte-kit/messages/en.json @@ -27,6 +27,7 @@ "user_bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", "profile": "Profile", + "profile_setUsername": "Choose your username", "profile_bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", "profile_personalInformation": "Personal information", "profile_security": "Security", @@ -47,6 +48,7 @@ "profile_updateSuccess": "Profile updated successfully", "auth_signIn": "Sign in", + "auth_singInWithGoogle": "Sign in with Google", "auth_signUp": "Sign up", "auth_usernameOrEmail": "Username or email", "auth_usernameOrEmailInvalid": "Invalid username or email", diff --git a/frontend/svelte-kit/messages/sr.json b/frontend/svelte-kit/messages/sr.json index 08ea08f0..1f27291a 100644 --- a/frontend/svelte-kit/messages/sr.json +++ b/frontend/svelte-kit/messages/sr.json @@ -27,6 +27,7 @@ "user_bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", "profile": "Profil", + "profile_setUsername": "Izaberi korisničko ime", "profile_bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", "profile_personalInformation": "Lični podaci", "profile_security": "Bezbednost", @@ -47,6 +48,7 @@ "profile_updateSuccess": "Uspešno ažuriran profil", "auth_signIn": "Prijavi se", + "auth_singInWithGoogle": "Prijavi se preko Google-a", "auth_signUp": "Registruj se", "auth_usernameOrEmail": "Korisničko ime ili email", "auth_usernameOrEmailInvalid": "Nevalidno korisničko ime ili email", diff --git a/frontend/svelte-kit/src/routes/+layout.server.ts b/frontend/svelte-kit/src/routes/+layout.server.ts index 990b5af3..f6525f82 100644 --- a/frontend/svelte-kit/src/routes/+layout.server.ts +++ b/frontend/svelte-kit/src/routes/+layout.server.ts @@ -1,10 +1,11 @@ import type { Profile } from '$lib/models/user/user'; +import { languageTag } from '$lib/paraglide/runtime'; import { makeRequest } from '$lib/server/apis/api'; -import { HttpRequest, isAdmin } from '$lib/server/utils/util'; -import { error } from '@sveltejs/kit'; +import { HttpRequest, isAdmin, removeAuth } from '$lib/server/utils/util'; +import { error, redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; -export const load = (async ({ locals, cookies }) => { +export const load = (async ({ locals, cookies, url }) => { if (!locals.userId) return { profile: null }; const response = await makeRequest( @@ -15,7 +16,13 @@ export const load = (async ({ locals, cookies }) => { cookies, ); - if ('error' in response) error(response.status, { message: response.error }); + if ('error' in response) { + if (response.status == 401) removeAuth(cookies, locals); + error(response.status, { message: response.error }); + } - return { profile: response as Profile, isAdmin: isAdmin(cookies.get('accessToken')) }; + const profile = response as Profile; + if (!profile.username && url.pathname !== `/${languageTag()}/profile`) redirect(302, '/profile'); + + return { profile, isAdmin: isAdmin(cookies.get('accessToken')) }; }) satisfies LayoutServerLoad; diff --git a/frontend/svelte-kit/src/routes/auth/sign-in/+page.svelte b/frontend/svelte-kit/src/routes/auth/sign-in/+page.svelte index 3c0667c6..9276a77f 100644 --- a/frontend/svelte-kit/src/routes/auth/sign-in/+page.svelte +++ b/frontend/svelte-kit/src/routes/auth/sign-in/+page.svelte @@ -1,8 +1,12 @@
-
-
-

{data.profile?.username}

-

{m.home_info()}

-
+
+ {#if !data.profile?.username} +

{m.profile_setUsername()}

+ + +
+ + + {#snippet children({ props })} + + + {/snippet} + + + + + {#if $delayed} + + + {m.general_save()} + + {:else} + {m.general_save()} + {/if} +
+
+
+ {:else} +
+

{data.profile?.username}

+

{m.home_info()}

+
+ {/if}
diff --git a/frontend/svelte-kit/src/routes/profile/schema.ts b/frontend/svelte-kit/src/routes/profile/schema.ts new file mode 100644 index 00000000..74ac7e30 --- /dev/null +++ b/frontend/svelte-kit/src/routes/profile/schema.ts @@ -0,0 +1,7 @@ +import * as m from '$lib/paraglide/messages.js'; +import { USERNAME_REGEX } from '$lib/regex'; +import { z } from 'zod'; + +export const setUsernameSchema = z.object({ + username: z.string().regex(USERNAME_REGEX, { message: m.profile_usernameInvalid() }), +});