From d0f23f3ae87eb8e62869ffe92ba3e350cda38e5f Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Thu, 18 Jun 2026 21:47:51 +0900 Subject: [PATCH 1/8] Rename ConnectedIdentityResponse -> UserIdentityDto --- .../com/icube/sim/tichu/auth/social/SocialAuthController.java | 2 +- .../com/icube/sim/tichu/auth/social/SocialAuthService.java | 2 +- .../{ConnectedIdentityResponse.java => UserIdentityDto.java} | 2 +- .../com/icube/sim/tichu/auth/social/UserIdentityService.java | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/main/java/com/icube/sim/tichu/auth/social/{ConnectedIdentityResponse.java => UserIdentityDto.java} (80%) diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java index 0fb7b7b..f2c0d40 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java @@ -23,7 +23,7 @@ public class SocialAuthController { private final SocialAuthService socialAuthService; @GetMapping - public List getConnectedIdentities() { + public List getConnectedIdentities() { return socialAuthService.getConnectedIdentities(); } diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java index df9b24e..8065607 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java @@ -14,7 +14,7 @@ public class SocialAuthService { private final UserIdentityService userIdentityService; private final OidcProviderClientRegistry oidcProviderClientRegistry; - public List getConnectedIdentities() { + public List getConnectedIdentities() { return userIdentityService.getIdentities(authService.getCurrentUserId()); } diff --git a/src/main/java/com/icube/sim/tichu/auth/social/ConnectedIdentityResponse.java b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityDto.java similarity index 80% rename from src/main/java/com/icube/sim/tichu/auth/social/ConnectedIdentityResponse.java rename to src/main/java/com/icube/sim/tichu/auth/social/UserIdentityDto.java index e9d567f..e7e4d1a 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/ConnectedIdentityResponse.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityDto.java @@ -2,7 +2,7 @@ import java.time.Instant; -public record ConnectedIdentityResponse( +public record UserIdentityDto( OidcProviderName provider, String providerEmail, Instant connectedAt diff --git a/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java index 2192004..dd2fc86 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/UserIdentityService.java @@ -18,9 +18,9 @@ public class UserIdentityService { private final UserIdentityRepository userIdentityRepository; @Transactional(readOnly = true) - public List getIdentities(Long userId) { + public List getIdentities(Long userId) { return userIdentityRepository.findAllByUserId(userId).stream() - .map(i -> new ConnectedIdentityResponse(i.getProvider(), i.getProviderEmail(), i.getCreatedAt())) + .map(i -> new UserIdentityDto(i.getProvider(), i.getProviderEmail(), i.getCreatedAt())) .toList(); } From 296f80586cb0c59b097784112d0d788ca68eb1d2 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Thu, 18 Jun 2026 22:20:40 +0900 Subject: [PATCH 2/8] Add UserController::getUserAccount - Remove redundant SocialAuthController::getConnectedIdentities --- .../tichu/auth/social/SocialAuthController.java | 5 ----- .../sim/tichu/auth/social/SocialAuthService.java | 4 ---- .../java/com/icube/sim/tichu/users/AccountDto.java | 11 +++++++++++ .../com/icube/sim/tichu/users/UserController.java | 14 ++++++++++++++ .../java/com/icube/sim/tichu/users/UserDto.java | 13 ++++++++----- .../java/com/icube/sim/tichu/users/UserMapper.java | 1 + .../com/icube/sim/tichu/users/UserService.java | 4 ++++ 7 files changed, 38 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/icube/sim/tichu/users/AccountDto.java diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java index f2c0d40..9f4545d 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthController.java @@ -22,11 +22,6 @@ public class SocialAuthController { private final RefreshTokenCookieFactory refreshTokenCookieFactory; private final SocialAuthService socialAuthService; - @GetMapping - public List getConnectedIdentities() { - return socialAuthService.getConnectedIdentities(); - } - @GetMapping("/{provider}/url") public SocialAuthUrlResponse getAuthorizationUrl(@PathVariable String provider) { return socialAuthService.getAuthorizationUrl(parseProvider(provider)); diff --git a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java index 8065607..6c1ecbf 100644 --- a/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java +++ b/src/main/java/com/icube/sim/tichu/auth/social/SocialAuthService.java @@ -14,10 +14,6 @@ public class SocialAuthService { private final UserIdentityService userIdentityService; private final OidcProviderClientRegistry oidcProviderClientRegistry; - public List getConnectedIdentities() { - return userIdentityService.getIdentities(authService.getCurrentUserId()); - } - public SocialAuthUrlResponse getAuthorizationUrl(OidcProviderName provider) { return oidcProviderClientRegistry.get(provider).getAuthorizationUrl(); } diff --git a/src/main/java/com/icube/sim/tichu/users/AccountDto.java b/src/main/java/com/icube/sim/tichu/users/AccountDto.java new file mode 100644 index 0000000..9cd28e7 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/users/AccountDto.java @@ -0,0 +1,11 @@ +package com.icube.sim.tichu.users; + +import com.icube.sim.tichu.auth.social.UserIdentityDto; + +import java.util.List; + +public record AccountDto( + UserDto user, + List identities +) { +} diff --git a/src/main/java/com/icube/sim/tichu/users/UserController.java b/src/main/java/com/icube/sim/tichu/users/UserController.java index bdb7ef6..f48d433 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserController.java +++ b/src/main/java/com/icube/sim/tichu/users/UserController.java @@ -1,6 +1,7 @@ package com.icube.sim.tichu.users; import com.icube.sim.tichu.auth.AuthService; +import com.icube.sim.tichu.auth.social.UserIdentityService; import com.icube.sim.tichu.common.ErrorDto; import jakarta.validation.Valid; import lombok.AllArgsConstructor; @@ -15,6 +16,19 @@ public class UserController { private final UserService userService; private final AuthService authService; + private final UserIdentityService userIdentityService; + + @GetMapping("/{id}") + public ResponseEntity getUserAccount(@PathVariable long id) { + var currentUserId = authService.getCurrentUserId(); + if (currentUserId != id) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + var user = userService.getUser(id); + var identities = userIdentityService.getIdentities(id); + return ResponseEntity.ok(new AccountDto(user, identities)); + } @PostMapping public UserDto register(@Valid @RequestBody RegisterUserRequest request) { diff --git a/src/main/java/com/icube/sim/tichu/users/UserDto.java b/src/main/java/com/icube/sim/tichu/users/UserDto.java index 949d99e..cf0c83b 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserDto.java +++ b/src/main/java/com/icube/sim/tichu/users/UserDto.java @@ -1,9 +1,12 @@ package com.icube.sim.tichu.users; -import lombok.Data; +import java.time.Instant; -@Data -public class UserDto { - private String name; - private String email; +public record UserDto( + Long id, + String name, + String email, + boolean hasPassword, + Instant createdAt +) { } diff --git a/src/main/java/com/icube/sim/tichu/users/UserMapper.java b/src/main/java/com/icube/sim/tichu/users/UserMapper.java index d79a036..7836bd0 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserMapper.java +++ b/src/main/java/com/icube/sim/tichu/users/UserMapper.java @@ -5,6 +5,7 @@ @Mapper(componentModel = "spring") public interface UserMapper { + @Mapping(target = "hasPassword", expression = "java(user.getPassword() != null)") UserDto toDto(User user); @Mapping(target = "id", ignore = true) diff --git a/src/main/java/com/icube/sim/tichu/users/UserService.java b/src/main/java/com/icube/sim/tichu/users/UserService.java index 5fc9b04..09deaa0 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserService.java +++ b/src/main/java/com/icube/sim/tichu/users/UserService.java @@ -11,6 +11,10 @@ public class UserService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; + public UserDto getUser(long id) { + return userMapper.toDto(userRepository.findById(id).orElseThrow()); + } + public UserDto register(RegisterUserRequest request) { if (userRepository.existsByEmail(request.getEmail())) { throw new DuplicateUserException(); From bb07a115bd2cdc3016f32dd2b9087857a196ac61 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Thu, 18 Jun 2026 22:20:52 +0900 Subject: [PATCH 3/8] Add UserController::updatePassword --- .../sim/tichu/users/NoPasswordException.java | 4 +++ .../tichu/users/UpdatePasswordRequest.java | 10 +++++++ .../icube/sim/tichu/users/UserController.java | 28 +++++++++++++++++++ .../icube/sim/tichu/users/UserService.java | 12 ++++++++ .../tichu/users/WrongPasswordException.java | 4 +++ 5 files changed, 58 insertions(+) create mode 100644 src/main/java/com/icube/sim/tichu/users/NoPasswordException.java create mode 100644 src/main/java/com/icube/sim/tichu/users/UpdatePasswordRequest.java create mode 100644 src/main/java/com/icube/sim/tichu/users/WrongPasswordException.java diff --git a/src/main/java/com/icube/sim/tichu/users/NoPasswordException.java b/src/main/java/com/icube/sim/tichu/users/NoPasswordException.java new file mode 100644 index 0000000..fa9de05 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/users/NoPasswordException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.users; + +public class NoPasswordException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/users/UpdatePasswordRequest.java b/src/main/java/com/icube/sim/tichu/users/UpdatePasswordRequest.java new file mode 100644 index 0000000..3300acc --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/users/UpdatePasswordRequest.java @@ -0,0 +1,10 @@ +package com.icube.sim.tichu.users; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdatePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min = 8, max = 64) String newPassword +) { +} diff --git a/src/main/java/com/icube/sim/tichu/users/UserController.java b/src/main/java/com/icube/sim/tichu/users/UserController.java index f48d433..f9ed53e 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserController.java +++ b/src/main/java/com/icube/sim/tichu/users/UserController.java @@ -49,10 +49,38 @@ public UserDto register(@Valid @RequestBody RegisterUserRequest request) { return ResponseEntity.noContent().build(); } + @PatchMapping("/{id}/password") + public ResponseEntity<@NonNull Void> updatePassword( + @PathVariable long id, + @Valid @RequestBody UpdatePasswordRequest request + ) { + var currentUserId = authService.getCurrentUserId(); + if (currentUserId != id) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + userService.updatePassword(id, request); + return ResponseEntity.noContent().build(); + } + @ExceptionHandler(DuplicateUserException.class) public ResponseEntity<@NonNull ErrorDto> handleDuplicateUser() { return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorDto( "The email is already registered." )); } + + @ExceptionHandler(NoPasswordException.class) + public ResponseEntity<@NonNull ErrorDto> handleNoPassword() { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorDto( + "The account does not have a password. It is social only account." + )); + } + + @ExceptionHandler(WrongPasswordException.class) + public ResponseEntity<@NonNull ErrorDto> handleWrongPassword() { + return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(new ErrorDto( + "Current password is incorrect." + )); + } } diff --git a/src/main/java/com/icube/sim/tichu/users/UserService.java b/src/main/java/com/icube/sim/tichu/users/UserService.java index 09deaa0..70958f9 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserService.java +++ b/src/main/java/com/icube/sim/tichu/users/UserService.java @@ -33,4 +33,16 @@ public void updateName(long userId, String name) { user.setName(name); userRepository.save(user); } + + public void updatePassword(long userId, UpdatePasswordRequest request) { + var user = userRepository.findById(userId).orElseThrow(); + if (user.getPassword() == null) { + throw new NoPasswordException(); + } + if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) { + throw new WrongPasswordException(); + } + user.setPassword(passwordEncoder.encode(request.newPassword())); + userRepository.save(user); + } } diff --git a/src/main/java/com/icube/sim/tichu/users/WrongPasswordException.java b/src/main/java/com/icube/sim/tichu/users/WrongPasswordException.java new file mode 100644 index 0000000..4db965c --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/users/WrongPasswordException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.users; + +public class WrongPasswordException extends RuntimeException { +} From 41e2512bfa3fa622752da57f6ae66c75737f6668 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Fri, 19 Jun 2026 03:21:38 +0900 Subject: [PATCH 4/8] Refactor UserController - Extract isNotCurrentUserId --- .../com/icube/sim/tichu/users/UserController.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/icube/sim/tichu/users/UserController.java b/src/main/java/com/icube/sim/tichu/users/UserController.java index f9ed53e..f9d983f 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserController.java +++ b/src/main/java/com/icube/sim/tichu/users/UserController.java @@ -20,8 +20,7 @@ public class UserController { @GetMapping("/{id}") public ResponseEntity getUserAccount(@PathVariable long id) { - var currentUserId = authService.getCurrentUserId(); - if (currentUserId != id) { + if (isNotCurrentUserId(id)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } @@ -40,8 +39,7 @@ public UserDto register(@Valid @RequestBody RegisterUserRequest request) { @PathVariable long id, @Valid @RequestBody UpdateUserRequest request ) { - var currentUserId = authService.getCurrentUserId(); - if (currentUserId != id) { + if (isNotCurrentUserId(id)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } @@ -54,8 +52,7 @@ public UserDto register(@Valid @RequestBody RegisterUserRequest request) { @PathVariable long id, @Valid @RequestBody UpdatePasswordRequest request ) { - var currentUserId = authService.getCurrentUserId(); - if (currentUserId != id) { + if (isNotCurrentUserId(id)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } @@ -63,6 +60,10 @@ public UserDto register(@Valid @RequestBody RegisterUserRequest request) { return ResponseEntity.noContent().build(); } + private boolean isNotCurrentUserId(long id) { + return authService.getCurrentUserId() != id; + } + @ExceptionHandler(DuplicateUserException.class) public ResponseEntity<@NonNull ErrorDto> handleDuplicateUser() { return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorDto( From 0ad13325e438811983b85fa9cd2936fdeebaf04c Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Thu, 18 Jun 2026 22:22:51 +0900 Subject: [PATCH 5/8] Rename DuplicateUserException -> DuplicateEmailException --- .../com/icube/sim/tichu/users/DuplicateEmailException.java | 4 ++++ .../com/icube/sim/tichu/users/DuplicateUserException.java | 4 ---- src/main/java/com/icube/sim/tichu/users/UserController.java | 4 ++-- src/main/java/com/icube/sim/tichu/users/UserService.java | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/icube/sim/tichu/users/DuplicateEmailException.java delete mode 100644 src/main/java/com/icube/sim/tichu/users/DuplicateUserException.java diff --git a/src/main/java/com/icube/sim/tichu/users/DuplicateEmailException.java b/src/main/java/com/icube/sim/tichu/users/DuplicateEmailException.java new file mode 100644 index 0000000..89083a4 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/users/DuplicateEmailException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.users; + +public class DuplicateEmailException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/users/DuplicateUserException.java b/src/main/java/com/icube/sim/tichu/users/DuplicateUserException.java deleted file mode 100644 index 763a2dc..0000000 --- a/src/main/java/com/icube/sim/tichu/users/DuplicateUserException.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.icube.sim.tichu.users; - -public class DuplicateUserException extends RuntimeException { -} diff --git a/src/main/java/com/icube/sim/tichu/users/UserController.java b/src/main/java/com/icube/sim/tichu/users/UserController.java index f9d983f..fe17a30 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserController.java +++ b/src/main/java/com/icube/sim/tichu/users/UserController.java @@ -64,8 +64,8 @@ private boolean isNotCurrentUserId(long id) { return authService.getCurrentUserId() != id; } - @ExceptionHandler(DuplicateUserException.class) - public ResponseEntity<@NonNull ErrorDto> handleDuplicateUser() { + @ExceptionHandler(DuplicateEmailException.class) + public ResponseEntity<@NonNull ErrorDto> handleDuplicateEmail() { return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorDto( "The email is already registered." )); diff --git a/src/main/java/com/icube/sim/tichu/users/UserService.java b/src/main/java/com/icube/sim/tichu/users/UserService.java index 70958f9..04434a9 100644 --- a/src/main/java/com/icube/sim/tichu/users/UserService.java +++ b/src/main/java/com/icube/sim/tichu/users/UserService.java @@ -17,7 +17,7 @@ public UserDto getUser(long id) { public UserDto register(RegisterUserRequest request) { if (userRepository.existsByEmail(request.getEmail())) { - throw new DuplicateUserException(); + throw new DuplicateEmailException(); } var user = userMapper.toEntity(request); From 162eed826ac27db79763c1505ed36a1bc01e1dba Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Fri, 19 Jun 2026 01:17:13 +0900 Subject: [PATCH 6/8] Add AccountPage --- frontend/src/AccountPage.module.css | 154 ++++++++++++++++++++++++++++ frontend/src/AccountPage.tsx | 113 ++++++++++++++++++++ frontend/src/types.ts | 23 +++++ 3 files changed, 290 insertions(+) create mode 100644 frontend/src/AccountPage.module.css create mode 100644 frontend/src/AccountPage.tsx diff --git a/frontend/src/AccountPage.module.css b/frontend/src/AccountPage.module.css new file mode 100644 index 0000000..cd39e54 --- /dev/null +++ b/frontend/src/AccountPage.module.css @@ -0,0 +1,154 @@ +.page { + display: flex; + justify-content: center; + padding: 2rem 1rem; + width: 100%; +} + +.card { + background: white; + border-radius: 1rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06); + padding: 2rem; + width: 100%; + max-width: 520px; +} + +.title { + margin: 0 0 1.5rem; + font-size: 1.4rem; + color: #222; +} + +.section { + margin-bottom: 2rem; +} + +.section:last-child { + margin-bottom: 0; +} + +.section-title { + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #999; + margin: 0 0 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #f0f0f0; +} + +.field { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; +} + +.label { + width: 110px; + flex-shrink: 0; + font-size: 0.875rem; + font-weight: 500; + color: #555; +} + +.field-value { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.field-text { + font-size: 0.95rem; + color: #333; +} + +.inline-edit { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.input { + flex: 1; + padding: 0.45rem 0.65rem; + border: 1px solid #ddd; + border-radius: 0.4rem; + color: #333; + outline: none; + transition: border-color 0.15s; +} + +.input:focus { + border-color: #4facfe; +} + +.btn-primary { + padding: 0.45rem 1rem; + background-color: #4facfe; + color: white; + border: none; + border-radius: 0.4rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; +} + +.btn-primary:hover { + background-color: #0089f2; +} + +.btn-secondary { + padding: 0.45rem 1rem; + background-color: white; + color: #555; + border: 1px solid #ddd; + border-radius: 0.4rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; +} + +.btn-secondary:hover { + background-color: #f5f5f5; + border-color: #bbb; +} + +.btn-secondary:disabled { + opacity: 0.5; + cursor: default; +} + +.btn-danger { + padding: 0.45rem 1rem; + background-color: white; + color: #e53935; + border: 1px solid #e53935; + border-radius: 0.4rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; +} + +.btn-danger:hover { + background-color: #ffebee; +} + +.error { + font-size: 0.875rem; + color: #e53935; + margin: 0.25rem 0; +} + +.success { + font-size: 0.875rem; + color: #2e7d32; + margin: 0.25rem 0; +} diff --git a/frontend/src/AccountPage.tsx b/frontend/src/AccountPage.tsx new file mode 100644 index 0000000..9e2d7dc --- /dev/null +++ b/frontend/src/AccountPage.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@/useAuth.tsx'; +import { useAxios } from '@/useAxios.tsx'; +import { AccountDto } from '@/types.ts'; +import styles from './AccountPage.module.css'; + +const AccountPage = () => { + const { user, reloadUser } = useAuth(); + const api = useAxios(); + const navigate = useNavigate(); + + const [accountData, setAccountData] = useState(null); + + const [editingName, setEditingName] = useState(false); + const [nameInput, setNameInput] = useState(''); + const [nameMessage, setNameMessage] = useState<{ text: string; error: boolean } | null>(null); + + const fetchAccountData = async () => { + if (!user) { + return; + } + try { + const res = await api.get(`/users/${user.id}`); + setAccountData(res.data); + } catch { + // leave accountData null; page will show empty state + } + }; + + useEffect(() => { + (async () => { + await fetchAccountData(); + })(); + }, []); + + const handleEditName = () => { + setNameInput(user?.name ?? ''); + setNameMessage(null); + setEditingName(true); + }; + + const handleSaveName = async () => { + if (!user) { + return; + } + setNameMessage(null); + try { + await api.patch(`/users/${user.id}`, { name: nameInput }); + await reloadUser(); + setEditingName(false); + setNameMessage({ text: '별명이 변경되었습니다.', error: false }); + } catch { + setNameMessage({ text: '별명 변경에 실패했습니다.', error: true }); + } + }; + + return ( +
+
+

계정

+ +
+

기본 정보

+ +
+ 이름 + {editingName ? ( +
+ setNameInput(e.target.value)} + autoFocus + /> + + +
+ ) : ( +
+ {user?.name} + +
+ )} +
+ {nameMessage && ( +

{nameMessage.text}

+ )} + +
+ 이메일 + {accountData?.user.email} +
+
+ + {accountData?.user.hasPassword && ( +
+

보안

+ +
+ )} +
+
+ ); +}; + +export default AccountPage; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0666d39..c786f69 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -49,3 +49,26 @@ export interface ChatMessage { export interface MemberMessage { members: MemberDto[]; } + +export enum OidcProviderName { + GOOGLE = 'GOOGLE', +} + +export interface UserIdentityDto { + provider: OidcProviderName; + providerEmail: string; + connectedAt: string; +} + +export interface UserDto { + id: number; + name: string; + email: string; + hasPassword: boolean; + createdAt: string; +} + +export interface AccountDto { + user: UserDto; + identities: UserIdentityDto[]; +} From a6438f8f765f899e7c180d69c272d12377775426 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Fri, 19 Jun 2026 01:17:24 +0900 Subject: [PATCH 7/8] Add ChangePasswordPage --- frontend/src/ChangePasswordPage.module.css | 109 +++++++++++++++++ frontend/src/ChangePasswordPage.tsx | 133 +++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 frontend/src/ChangePasswordPage.module.css create mode 100644 frontend/src/ChangePasswordPage.tsx diff --git a/frontend/src/ChangePasswordPage.module.css b/frontend/src/ChangePasswordPage.module.css new file mode 100644 index 0000000..88f0d43 --- /dev/null +++ b/frontend/src/ChangePasswordPage.module.css @@ -0,0 +1,109 @@ +.page { + display: flex; + justify-content: center; + padding: 2rem 1rem; + width: 100%; +} + +.card { + background: white; + border-radius: 1rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06); + padding: 2rem; + width: 100%; + max-width: 420px; +} + +.title { + margin: 0 0 1.5rem; + font-size: 1.4rem; + color: #222; +} + +.form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.label { + font-size: 0.875rem; + font-weight: 500; + color: #555; +} + +.input { + padding: 0.45rem 0.65rem; + border: 1px solid #ddd; + border-radius: 0.4rem; + color: #333; + outline: none; + transition: border-color 0.15s; + font-size: 1rem; +} + +.input:focus { + border-color: #4facfe; +} + +.actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} + +.btn-primary { + padding: 0.45rem 1rem; + background-color: #4facfe; + color: white; + border: none; + border-radius: 0.4rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; +} + +.btn-primary:hover:not(:disabled) { + background-color: #0089f2; +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: default; +} + +.btn-secondary { + padding: 0.45rem 1rem; + background-color: white; + color: #555; + border: 1px solid #ddd; + border-radius: 0.4rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; +} + +.btn-secondary:hover { + background-color: #f5f5f5; + border-color: #bbb; +} + +.error { + font-size: 0.875rem; + color: #e53935; + margin: 0.25rem 0; +} + +.success { + font-size: 0.875rem; + color: #2e7d32; + margin: 0.25rem 0; +} diff --git a/frontend/src/ChangePasswordPage.tsx b/frontend/src/ChangePasswordPage.tsx new file mode 100644 index 0000000..93dfee9 --- /dev/null +++ b/frontend/src/ChangePasswordPage.tsx @@ -0,0 +1,133 @@ +import { useState, useEffect, SubmitEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@/useAuth.tsx'; +import { useAxios } from '@/useAxios.tsx'; +import { AccountDto } from '@/types.ts'; +import styles from './ChangePasswordPage.module.css'; + +const ChangePasswordPage = () => { + const { user } = useAuth(); + const api = useAxios(); + const navigate = useNavigate(); + + const [ready, setReady] = useState(false); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!user) { + return; + } + (async () => { + try { + const res = await api.get(`/users/${user.id}`); + if (!res.data.user.hasPassword) { + navigate('/account', { replace: true }); + return; + } + setReady(true); + } catch { + navigate('/account', { replace: true }); + } + })(); + }, []); + + const handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); + if (!user) { + return; + } + setMessage(null); + + if (!currentPassword) { + setMessage({ text: '현재 비밀번호를 입력해 주세요.', error: true }); + return; + } + + if (newPassword !== confirmPassword) { + setMessage({ text: '새 비밀번호가 일치하지 않습니다.', error: true }); + return; + } + + setSubmitting(true); + try { + await api.patch(`/users/${user.id}/password`, { currentPassword, newPassword }); + navigate('/account'); + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status; + if (status === 406) { + setMessage({ text: '현재 비밀번호가 올바르지 않습니다.', error: true }); + } else { + setMessage({ text: '비밀번호 변경에 실패했습니다.', error: true }); + } + } finally { + setSubmitting(false); + } + }; + + if (!ready) { + return null; + } + + return ( +
+
+

비밀번호 변경

+
+
+ + setCurrentPassword(e.target.value)} + required + autoFocus + /> +
+
+ + setNewPassword(e.target.value)} + minLength={8} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + required + /> +
+ {message && ( +

{message.text}

+ )} +
+ + +
+
+
+
+ ); +}; + +export default ChangePasswordPage; From da27bb34de93061c11bd69ee2b93cfa2300f6381 Mon Sep 17 00:00:00 2001 From: ICubE01 Date: Fri, 19 Jun 2026 01:18:20 +0900 Subject: [PATCH 8/8] Update NavBar and App.tsx --- frontend/src/App.tsx | 4 ++ frontend/src/NavBar.module.css | 68 ++++++++++++++++++-------- frontend/src/NavBar.tsx | 87 ++++++++++++++++++---------------- 3 files changed, 98 insertions(+), 61 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9b613b..741326e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,8 @@ import SignupPage from '@/SignupPage.tsx'; import GoogleCallbackPage from '@/GoogleCallbackPage.tsx'; import InitNamePage from '@/InitNamePage.tsx'; import NavBar from "@/NavBar.tsx"; +import AccountPage from '@/AccountPage.tsx'; +import ChangePasswordPage from '@/ChangePasswordPage.tsx'; import HomePage from '@/HomePage/HomePage.tsx'; import RoomDetailPage from '@/RoomDetailPage.tsx'; import './App.css'; @@ -47,6 +49,8 @@ const AppContent = () => { }> }/> }/> + }/> + }/> {user?.role === 'ADMIN' && ( Loading...}> diff --git a/frontend/src/NavBar.module.css b/frontend/src/NavBar.module.css index 3651950..3aa9826 100644 --- a/frontend/src/NavBar.module.css +++ b/frontend/src/NavBar.module.css @@ -23,39 +23,69 @@ display: flex; align-items: center; gap: 15px; + position: relative; } -.settings-btn, .user-name { - cursor: pointer; +.home-link { font-size: 1rem; - padding: 0.5rem 1rem; + font-weight: 600; + color: #333; + text-decoration: none; + padding: 0.5rem 0.25rem; border-radius: 0.5rem; - transition: background-color 0.2s; + transition: color 0.15s; } -.settings-btn:hover, .user-name:hover { - background-color: #f0f0f0; +.home-link:hover { + color: #4facfe; } -.logout-btn { +.user-name { cursor: pointer; - color: #ff4d4f; font-size: 1rem; - border: none; - background: none; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + transition: background-color 0.2s; + user-select: none; +} + +.user-name:hover { + background-color: #f0f0f0; } -/* 팝업 스타일 */ -.settings-popup { +.dropdown { position: absolute; - top: 60px; - left: 20px; - width: 200px; - height: 150px; + top: calc(100% + 0.5rem); + right: 0; + min-width: 7rem; background: white; border: 1px solid #ddd; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); - padding: 20px; + border-radius: 0.5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + overflow: hidden; z-index: 101; - border-radius: 8px; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.65rem 1rem; + font-size: 0.9rem; + text-align: left; + text-decoration: none; + color: #333; + background: none; + border: none; + cursor: pointer; + transition: background-color 0.15s; + box-sizing: border-box; +} + +.dropdown-item:hover { + background-color: #f5f5f5; +} + +.logout-item { + color: #e53935; + border-top: 1px solid #eee; } diff --git a/frontend/src/NavBar.tsx b/frontend/src/NavBar.tsx index 18f05d8..2e9a296 100644 --- a/frontend/src/NavBar.tsx +++ b/frontend/src/NavBar.tsx @@ -1,57 +1,60 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { Link, useMatch } from 'react-router-dom'; import { useAuth } from '@/useAuth.tsx'; import styles from './NavBar.module.css'; const NavBar = () => { const { user, logout } = useAuth(); - const [showSettings, setShowSettings] = useState(false); - const [showLogout, setShowLogout] = useState(false); + const roomMatch = useMatch('/rooms/:roomId'); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!dropdownOpen) { + return; + } + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [dropdownOpen]); return ( - <> - ); };