From a836b79852c1443f209a302b51d5d315ef059fc9 Mon Sep 17 00:00:00 2001 From: zio Date: Tue, 14 Jul 2026 06:26:50 +0900 Subject: [PATCH] feat(account): my-page profile, password change and notification prefs (G-08) AccountSettings endpoints (profile update, password change with current -password check, notification preference toggles persisted via V55; absent row = server defaults all-on). MyPage wires the settings UI. Co-Authored-By: Claude Fable 5 --- .../auth/AccountSettingsController.java | 72 +++++ .../kintex/auth/AccountSettingsService.java | 125 ++++++++ .../auth/dto/ChangePasswordRequest.java | 15 + .../kintex/auth/dto/NotificationPrefsDto.java | 17 ++ .../kintex/auth/dto/ProfileUpdateRequest.java | 17 ++ .../auth/mapper/NotificationPrefMapper.java | 42 +++ .../kintex/auth/mapper/UserMapper.java | 15 + .../migration/V55__user_notification_pref.sql | 16 ++ .../resources/mybatis/mapper/UserMapper.xml | 25 ++ src/frontend/src/screens/work/MyPage.tsx | 271 ++++++++++++++---- src/frontend/src/screens/work/myPageApi.ts | 51 ++++ 11 files changed, 607 insertions(+), 59 deletions(-) create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/auth/AccountSettingsController.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/auth/AccountSettingsService.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ChangePasswordRequest.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/auth/dto/NotificationPrefsDto.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ProfileUpdateRequest.java create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/NotificationPrefMapper.java create mode 100644 src/backend/src/main/resources/db/migration/V55__user_notification_pref.sql create mode 100644 src/frontend/src/screens/work/myPageApi.ts diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/AccountSettingsController.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/AccountSettingsController.java new file mode 100644 index 0000000..66bb5f0 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/AccountSettingsController.java @@ -0,0 +1,72 @@ +package com.zioinfo.kintex.auth; + +import com.zioinfo.kintex.auth.dto.ChangePasswordRequest; +import com.zioinfo.kintex.auth.dto.MeResponse; +import com.zioinfo.kintex.auth.dto.NotificationPrefsDto; +import com.zioinfo.kintex.auth.dto.ProfileUpdateRequest; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * 마이페이지(SCR-48) 계정 설정 API — 프로필 수정·비밀번호 변경·알림 설정(GAP G-08). + * + * 대상은 항상 인증 principal 본인이다(요청 본문/경로로 userId 미수용). {@code GET /api/auth/me} 는 + * 기존 {@link AuthController} 소관(회귀 0) — 여기서는 변경(mutation)만 담당한다. + */ +@RestController +@RequestMapping("/api/auth/me") +public class AccountSettingsController { + + private final AccountSettingsService service; + private final EventAccessGuard guard; + + public AccountSettingsController(AccountSettingsService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** 프로필 수정 — 본인만. email·role·dept 는 이 경로로 변경 불가(서버 화이트리스트). */ + @Audited(action = "PROFILE_UPDATE", targetType = "app_user") + @PutMapping + public ApiResponse updateProfile(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody ProfileUpdateRequest req) { + guard.require(principal); + return ApiResponse.ok(service.updateProfile(principal, req)); + } + + /** 비밀번호 변경 — 현재 비밀번호 검증 후 갱신. 본문(원문)은 감사/로그 미기록(계약 §0-3). */ + @Audited(action = "PASSWORD_CHANGE", targetType = "app_user") + @PostMapping("/password") + public ApiResponse changePassword(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody ChangePasswordRequest req) { + guard.require(principal); + service.changePassword(principal, req); + return ApiResponse.ok(null); + } + + /** 알림 설정 조회 — 미설정 시 서버 기본값. */ + @GetMapping("/notification-prefs") + public ApiResponse getNotificationPrefs( + @AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + return ApiResponse.ok(service.getNotificationPrefs(principal)); + } + + /** 알림 설정 저장(멱등) — 저장값 반환. */ + @Audited(action = "NOTIFICATION_PREF_SAVE", targetType = "app_user") + @PutMapping("/notification-prefs") + public ApiResponse saveNotificationPrefs( + @AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody NotificationPrefsDto req) { + guard.require(principal); + return ApiResponse.ok(service.saveNotificationPrefs(principal, req)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/AccountSettingsService.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/AccountSettingsService.java new file mode 100644 index 0000000..977780d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/AccountSettingsService.java @@ -0,0 +1,125 @@ +package com.zioinfo.kintex.auth; + +import com.zioinfo.kintex.auth.dto.ChangePasswordRequest; +import com.zioinfo.kintex.auth.dto.MeResponse; +import com.zioinfo.kintex.auth.dto.NotificationPrefsDto; +import com.zioinfo.kintex.auth.dto.ProfileUpdateRequest; +import com.zioinfo.kintex.auth.mapper.NotificationPrefMapper; +import com.zioinfo.kintex.auth.mapper.UserMapper; +import com.zioinfo.kintex.auth.profile.ProfilePhotoService; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +/** + * 마이페이지(SCR-48) 계정 설정 서비스 — 프로필 수정·비밀번호 변경·알림 설정(GAP G-08). + * + *

기존 {@code AuthServiceImpl}(로그인·me)·2FA 로직은 교체하지 않고 순증한다. + * 모든 변경은 본인({@code principal.userId()})에 대해서만 수행하며 대상 userId 를 요청 본문으로 받지 않는다. + * 보안 불변(계약 §0-3): password_hash 는 검증 용도로만 조회하고 응답/로그에 노출하지 않으며, + * 프로필 수정은 화이트리스트(display_name·phone)만 허용한다(email·role·dept 변경 불가). + */ +@Service +public class AccountSettingsService { + + private static final Logger log = LoggerFactory.getLogger(AccountSettingsService.class); + + private final UserMapper userMapper; + private final NotificationPrefMapper notifPrefMapper; + private final PasswordEncoder passwordEncoder; + + public AccountSettingsService(UserMapper userMapper, NotificationPrefMapper notifPrefMapper, + PasswordEncoder passwordEncoder) { + this.userMapper = userMapper; + this.notifPrefMapper = notifPrefMapper; + this.passwordEncoder = passwordEncoder; + } + + /** 프로필 수정 — 화이트리스트(displayName·phone)만. 갱신 후 최신 {@link MeResponse} 반환(웹 부트스트랩 재사용). */ + @Transactional + public MeResponse updateProfile(KintexPrincipal principal, ProfileUpdateRequest req) { + String displayName = req.displayName().trim(); + if (displayName.isEmpty()) { + throw new ApiException(ErrorCode.VALIDATION, "이름을 입력해 주세요."); + } + String phone = blankToNull(req.phone()); + int rows = userMapper.updateProfile(principal.userId(), displayName, phone); + if (rows == 0) { + throw new ApiException(ErrorCode.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return buildMe(principal); + } + + /** 비밀번호 변경 — 현재 비밀번호 검증 + 정책(신규 != 현재) 후 해시 갱신. */ + @Transactional + public void changePassword(KintexPrincipal principal, ChangePasswordRequest req) { + String currentHash = userMapper.findPasswordHash(principal.userId()); + if (currentHash == null || !passwordEncoder.matches(req.currentPassword(), currentHash)) { + // 현재 비밀번호 불일치 — 일반화 메시지(구체 사유 미노출). + throw new ApiException(ErrorCode.UNAUTHORIZED, "현재 비밀번호가 올바르지 않습니다."); + } + if (passwordEncoder.matches(req.newPassword(), currentHash)) { + throw new ApiException(ErrorCode.VALIDATION, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); + } + userMapper.updatePassword(principal.userId(), passwordEncoder.encode(req.newPassword())); + // 원문·해시 미기록(계약 §0-3). + log.info("비밀번호 변경 완료(userId={})", principal.userId()); + } + + /** 알림 설정 조회 — 미설정 사용자는 서버 기본값(전체 수신, 이메일 off). */ + public NotificationPrefsDto getNotificationPrefs(KintexPrincipal principal) { + Map row = notifPrefMapper.find(principal.userId()); + if (row == null) { + return NotificationPrefsDto.defaults(); + } + return new NotificationPrefsDto( + bool(row.get("notifyDeadline"), true), + bool(row.get("notifyApproval"), true), + bool(row.get("notifyPayment"), true), + bool(row.get("emailEnabled"), false)); + } + + /** 알림 설정 저장(upsert, 멱등) — 저장값을 그대로 반환. */ + @Transactional + public NotificationPrefsDto saveNotificationPrefs(KintexPrincipal principal, NotificationPrefsDto req) { + notifPrefMapper.upsert(principal.userId(), + req.notifyDeadline(), req.notifyApproval(), req.notifyPayment(), req.emailEnabled()); + return req; + } + + /** 갱신된 프로필로 {@link MeResponse} 재조립 — DB 기준 displayName·phone(토큰 stale 회피) + principal 역할. */ + private MeResponse buildMe(KintexPrincipal principal) { + Map p = userMapper.findProfile(principal.userId()); + String displayName = p != null && p.get("displayName") != null + ? String.valueOf(p.get("displayName")) : principal.displayName(); + String email = p == null ? null : str(p.get("email")); + String phone = p == null ? null : str(p.get("phone")); + String deptId = p == null ? null : str(p.get("deptId")); + boolean hasPhoto = p != null && Boolean.TRUE.equals(bool(p.get("hasPhoto"), false)); + String photoUrl = hasPhoto ? ProfilePhotoService.serveUrl(principal.userId()) : null; + return new MeResponse( + principal.userId(), displayName, principal.eventRoles(), + principal.hallManager(), principal.roleCode(), principal.tenantId(), + email, phone, deptId, photoUrl); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } + + private static boolean bool(Object o, boolean dflt) { + if (o == null) return dflt; + if (o instanceof Boolean b) return b; + return Boolean.parseBoolean(String.valueOf(o)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ChangePasswordRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..8adda5c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ChangePasswordRequest.java @@ -0,0 +1,15 @@ +package com.zioinfo.kintex.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 마이페이지 비밀번호 변경 요청(SCR-48). 현재 비밀번호 검증 후 새 비밀번호로 갱신한다. + * 비밀번호 정책은 회원가입/재설정과 동일(최소 8자 · 최대 100자, {@code RegisterRequest}/{@code ResetPasswordRequest} 정합). + * 보안 불변(계약 §0-3): currentPassword·newPassword 원문·해시는 응답/로그에 절대 노출 금지. + */ +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min = 8, max = 100) String newPassword +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/NotificationPrefsDto.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/NotificationPrefsDto.java new file mode 100644 index 0000000..ed8b155 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/NotificationPrefsDto.java @@ -0,0 +1,17 @@ +package com.zioinfo.kintex.auth.dto; + +/** + * 사용자 알림 설정 — 조회/저장 공용 페이로드(마이페이지 SCR-48 알림설정). + * 유형 토글(마감·승인·결제) + 이메일 채널. 미설정 사용자는 서버 기본값(수신 on, 이메일 off)으로 응답한다. + */ +public record NotificationPrefsDto( + boolean notifyDeadline, + boolean notifyApproval, + boolean notifyPayment, + boolean emailEnabled +) { + /** 신규(미설정) 사용자 기본값 — 인앱 알림 전체 수신, 이메일 채널 off. */ + public static NotificationPrefsDto defaults() { + return new NotificationPrefsDto(true, true, true, false); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ProfileUpdateRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ProfileUpdateRequest.java new file mode 100644 index 0000000..8a239fb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ProfileUpdateRequest.java @@ -0,0 +1,17 @@ +package com.zioinfo.kintex.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 마이페이지 프로필 수정 요청(SCR-48). 서버 화이트리스트: displayName·phone 만 수정 가능하다. + * 이메일(로그인 식별자)·부서·역할은 이 경로로 변경할 수 없다(계약 §0-3 · 관리자/조직 관리 소관). + * userId 는 항상 인증 principal 에서 온다(요청 본문 미수용). + */ +public record ProfileUpdateRequest( + @NotBlank @Size(min = 1, max = 120) String displayName, + // 선택 — 숫자/하이픈/공백/괄호/+ 만 허용(형식 관대, 빈 값이면 연락처 삭제). + @Size(max = 40) @Pattern(regexp = "^[0-9+()\\-\\s]*$", message = "연락처 형식이 올바르지 않습니다.") String phone +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/NotificationPrefMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/NotificationPrefMapper.java new file mode 100644 index 0000000..6a286e9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/NotificationPrefMapper.java @@ -0,0 +1,42 @@ +package com.zioinfo.kintex.auth.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.Map; + +/** + * 사용자 알림 설정 매퍼(마이페이지 SCR-48). {@code user_notification_pref} 1행/사용자. + * 미행 사용자는 서비스에서 서버 기본값으로 취급한다(별도 시드 없음). 민감 컬럼 없음. + */ +@Mapper +public interface NotificationPrefMapper { + + /** 사용자 알림 설정 조회. 없으면 null(서비스에서 기본값 대체). */ + @Select(""" + SELECT notify_deadline AS "notifyDeadline", notify_approval AS "notifyApproval", + notify_payment AS "notifyPayment", email_enabled AS "emailEnabled" + FROM user_notification_pref WHERE user_id = #{userId} + """) + Map find(@Param("userId") String userId); + + /** 알림 설정 upsert(멱등) — 본인만 호출됨(서비스에서 principal.userId 강제). */ + @Update(""" + INSERT INTO user_notification_pref + (user_id, notify_deadline, notify_approval, notify_payment, email_enabled, updated_at) + VALUES (#{userId}, #{notifyDeadline}, #{notifyApproval}, #{notifyPayment}, #{emailEnabled}, now()) + ON CONFLICT (user_id) DO UPDATE SET + notify_deadline = EXCLUDED.notify_deadline, + notify_approval = EXCLUDED.notify_approval, + notify_payment = EXCLUDED.notify_payment, + email_enabled = EXCLUDED.email_enabled, + updated_at = now() + """) + int upsert(@Param("userId") String userId, + @Param("notifyDeadline") boolean notifyDeadline, + @Param("notifyApproval") boolean notifyApproval, + @Param("notifyPayment") boolean notifyPayment, + @Param("emailEnabled") boolean emailEnabled); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/UserMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/UserMapper.java index aed8bdc..ea4efff 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/UserMapper.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/UserMapper.java @@ -35,4 +35,19 @@ public interface UserMapper { int updatePhoto(@Param("userId") String userId, @Param("photoPath") String photoPath, @Param("photoContentType") String photoContentType); + + /** + * 프로필 수정(마이페이지) — 화이트리스트 컬럼(display_name·phone)만 갱신. 본인만 호출됨(서비스에서 principal.userId 강제). + * email·role·dept 등은 이 경로로 변경 불가(서버 권위). 영향 행수 반환. + */ + int updateProfile(@Param("userId") String userId, + @Param("displayName") String displayName, + @Param("phone") String phone); + + /** 비밀번호 검증용 해시 조회(본인 변경 시 현재 비밀번호 대조 전용). 응답 DTO로 절대 노출 금지. 없으면 null. */ + String findPasswordHash(@Param("userId") String userId); + + /** 비밀번호 해시 갱신(본인 변경) — 서비스에서 현재 비밀번호 검증 후에만 호출. 영향 행수 반환. */ + int updatePassword(@Param("userId") String userId, + @Param("passwordHash") String passwordHash); } diff --git a/src/backend/src/main/resources/db/migration/V55__user_notification_pref.sql b/src/backend/src/main/resources/db/migration/V55__user_notification_pref.sql new file mode 100644 index 0000000..ede8793 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V55__user_notification_pref.sql @@ -0,0 +1,16 @@ +-- 킨텍스 — 사용자별 알림 설정(순증, 멱등). 마이페이지(SCR-48) 알림설정 서버 동기화(GAP G-08). +-- V1~V53 불변. 본 마이그레이션은 additive만 수행한다(파괴적 변경 없음). +-- 유형(마감/승인/결제) 토글 + 채널(이메일) 토글을 사용자 1행으로 보관한다. +-- 미행(未行) 사용자는 서비스에서 서버 기본값(모두 수신)으로 취급한다 — 별도 시드 불필요. + +CREATE TABLE IF NOT EXISTS user_notification_pref ( + user_id varchar(40) PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE, + notify_deadline boolean NOT NULL DEFAULT true, -- 마감 D-데이 알림 + notify_approval boolean NOT NULL DEFAULT true, -- 승인·검수 알림 + notify_payment boolean NOT NULL DEFAULT true, -- 결제·정산 알림 + email_enabled boolean NOT NULL DEFAULT false, -- 채널: 이메일 병행 발송 + updated_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE user_notification_pref IS '사용자별 알림 수신 설정(유형 토글 + 이메일 채널) — 마이페이지 저장'; +COMMENT ON COLUMN user_notification_pref.email_enabled IS '이메일 병행 발송 채널 on/off(기본 off — 인앱 알림만)'; diff --git a/src/backend/src/main/resources/mybatis/mapper/UserMapper.xml b/src/backend/src/main/resources/mybatis/mapper/UserMapper.xml index 84e0029..a8b162b 100644 --- a/src/backend/src/main/resources/mybatis/mapper/UserMapper.xml +++ b/src/backend/src/main/resources/mybatis/mapper/UserMapper.xml @@ -74,4 +74,29 @@ WHERE id = #{userId} + + + UPDATE app_user + SET display_name = #{displayName}, + phone = #{phone}, + updated_at = now() + WHERE id = #{userId} + + + + + + + + UPDATE app_user + SET password_hash = #{passwordHash}, + updated_at = now() + WHERE id = #{userId} + + diff --git a/src/frontend/src/screens/work/MyPage.tsx b/src/frontend/src/screens/work/MyPage.tsx index e98e9d2..d0e84b5 100644 --- a/src/frontend/src/screens/work/MyPage.tsx +++ b/src/frontend/src/screens/work/MyPage.tsx @@ -1,19 +1,22 @@ /* * SCR-48 마이페이지·환경설정. 참조: UIWS auth/MyProfilePage. * 좌: 탭 레일(프로필·보안·알림설정·테마/언어) / 우: 설정 폼. - * 백엔드: /api/auth/me·/api/auth/otp/status. 2FA는 기존 /otp-setup 화면으로 연결(중복 구현 금지). - * ★ 갭: 프로필 수정·알림 규칙·환경설정 저장 엔드포인트 부재 → 테마/언어는 클라이언트 로컬 저장. 07_work_api_gaps.md 기록. + * 백엔드: /api/auth/me·/api/auth/me/*(프로필·비밀번호·알림설정)·/api/auth/otp/status. + * 2FA는 기존 /otp-setup 화면으로 연결(중복 구현 금지). + * GAP G-08 해소: 프로필 수정(PUT /api/auth/me)·비밀번호 변경(POST /api/auth/me/password)· + * 알림설정 서버 동기화(GET/PUT /api/auth/me/notification-prefs). 테마/언어만 클라이언트 로컬 저장. */ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { authApi } from '../../api/endpoints'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useAuthStore } from '../../store/authStore'; import { Button } from '../../components/ui/Button'; import { RoleBadge } from '../../components/ui/Badge'; import { ErrorState, Skeleton } from '../../components/ui/States'; import { IconBell, IconCheckCircle, IconSettings, IconShieldCheck, IconUser } from '../../components/ui/icons'; -import { useToast } from './workShared'; +import { authApi } from '../../api/endpoints'; +import { useToast, errMessage } from './workShared'; +import { myPageApi, type NotificationPrefs } from './myPageApi'; import './work.css'; type Tab = 'profile' | 'security' | 'notify' | 'theme'; @@ -28,17 +31,8 @@ const PREF_KEY = 'kintex.prefs'; interface Prefs { theme: 'light' | 'dark'; lang: 'ko' | 'en' | 'zh' | 'ja'; - notifyDeadline: boolean; - notifyApproval: boolean; - notifyPayment: boolean; } -const DEFAULT_PREFS: Prefs = { - theme: 'light', - lang: 'ko', - notifyDeadline: true, - notifyApproval: true, - notifyPayment: true, -}; +const DEFAULT_PREFS: Prefs = { theme: 'light', lang: 'ko' }; function loadPrefs(): Prefs { try { @@ -48,20 +42,93 @@ function loadPrefs(): Prefs { } } +const NOTIFY_ROWS: { key: keyof NotificationPrefs; label: string }[] = [ + { key: 'notifyDeadline', label: '마감 D-데이 알림' }, + { key: 'notifyApproval', label: '승인·검수 알림' }, + { key: 'notifyPayment', label: '결제·정산 알림' }, + { key: 'emailEnabled', label: '이메일 병행 발송' }, +]; + export function MyPage() { const { show, node: toast } = useToast(); + const qc = useQueryClient(); const storeUser = useAuthStore((s) => s.user); const workspaces = useAuthStore((s) => s.workspaces); const [tab, setTab] = useState('profile'); const [prefs, setPrefs] = useState(loadPrefs); - const meQ = useQuery({ queryKey: ['me'], queryFn: () => authApi.me() }); + const meQ = useQuery({ queryKey: ['me'], queryFn: () => myPageApi.me() }); const otpQ = useQuery({ queryKey: ['otp-status'], queryFn: () => authApi.otpStatus() }); + // ── 프로필 편집 폼 상태(서버 로드 시 동기화) ── + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); useEffect(() => { - document.documentElement.setAttribute('data-theme', prefs.theme); - document.documentElement.setAttribute('lang', prefs.lang); - }, [prefs.theme, prefs.lang]); + if (meQ.data) { + setName(meQ.data.displayName ?? ''); + setPhone(meQ.data.phone ?? ''); + } + }, [meQ.data]); + + const profileMut = useMutation({ + mutationFn: () => myPageApi.updateProfile({ displayName: name.trim(), phone: phone.trim() }), + onSuccess: (updated) => { + qc.setQueryData(['me'], updated); + qc.invalidateQueries({ queryKey: ['me'] }); + show('프로필이 저장되었습니다.'); + }, + onError: (e) => show(errMessage(e)), + }); + + // ── 비밀번호 변경 상태 ── + const [curPw, setCurPw] = useState(''); + const [newPw, setNewPw] = useState(''); + const [confirmPw, setConfirmPw] = useState(''); + const pwMut = useMutation({ + mutationFn: () => myPageApi.changePassword({ currentPassword: curPw, newPassword: newPw }), + onSuccess: () => { + setCurPw(''); + setNewPw(''); + setConfirmPw(''); + show('비밀번호가 변경되었습니다.'); + }, + onError: (e) => show(errMessage(e)), + }); + function submitPassword() { + if (newPw.length < 8) { + show('새 비밀번호는 8자 이상이어야 합니다.'); + return; + } + if (newPw !== confirmPw) { + show('새 비밀번호가 일치하지 않습니다.'); + return; + } + if (newPw === curPw) { + show('새 비밀번호는 현재 비밀번호와 달라야 합니다.'); + return; + } + pwMut.mutate(); + } + + // ── 알림 설정(서버 동기화) ── + const notifQ = useQuery({ queryKey: ['notif-prefs'], queryFn: () => myPageApi.getNotificationPrefs() }); + const notifMut = useMutation({ + mutationFn: (next: NotificationPrefs) => myPageApi.saveNotificationPrefs(next), + onSuccess: (saved) => { + qc.setQueryData(['notif-prefs'], saved); + show('알림 설정이 저장되었습니다.'); + }, + onError: (e) => { + qc.invalidateQueries({ queryKey: ['notif-prefs'] }); // 실패 시 서버 값으로 롤백 + show(errMessage(e)); + }, + }); + function toggleNotify(key: keyof NotificationPrefs, value: boolean) { + if (!notifQ.data) return; + const next = { ...notifQ.data, [key]: value }; + qc.setQueryData(['notif-prefs'], next); // 낙관적 반영 + notifMut.mutate(next); + } function savePrefs(next: Prefs) { setPrefs(next); @@ -69,8 +136,14 @@ export function MyPage() { show('설정이 저장되었습니다.'); } + useEffect(() => { + document.documentElement.setAttribute('data-theme', prefs.theme); + document.documentElement.setAttribute('lang', prefs.lang); + }, [prefs.theme, prefs.lang]); + const displayName = meQ.data?.displayName ?? storeUser?.displayName ?? '사용자'; - const roles = meQ.data?.eventRoles ? Object.values(meQ.data.eventRoles) : []; + const roles = Array.from(new Set(workspaces.map((w) => w.myRole).filter(Boolean))); + const dirty = name.trim() !== (meQ.data?.displayName ?? '') || phone.trim() !== (meQ.data?.phone ?? ''); return (

@@ -106,30 +179,65 @@ export function MyPage() { ) : meQ.isError ? ( meQ.refetch()} /> ) : ( -
- {displayName[0] ?? '·'} -
- {displayName} - 사용자 ID: {meQ.data?.userId} -
- {meQ.data?.hallManager && 홀매니저} - {roles.map((r, i) => ( - - ))} + <> +
+ {displayName[0] ?? '·'} +
+ 사용자 ID: {meQ.data?.userId} + {meQ.data?.email && ( + {meQ.data.email} + )} +
+ {meQ.data?.hallManager && 홀매니저} + {roles.map((r, i) => ( + + ))} +
+ 참여 행사 {workspaces.length}건
- - 참여 행사 {workspaces.length}건 -
-
+ +
+ + + +
+
+ +
+ )} -

- 프로필 정보 수정은 준비 중입니다. -

)} - {/* 보안 (2FA) */} + {/* 보안 (2FA + 비밀번호 변경) */} {tab === 'security' && ( <>
@@ -162,6 +270,50 @@ export function MyPage() {
)} + +
+

비밀번호 변경

+
+
+ + + +
+
+ +
)} @@ -171,27 +323,28 @@ export function MyPage() {

알림 설정

-
- {( - [ - ['notifyDeadline', '마감 D-데이 알림'], - ['notifyApproval', '승인·검수 알림'], - ['notifyPayment', '결제·정산 알림'], - ] as const - ).map(([key, label]) => ( - - ))} -

- 현재 알림 설정은 이 기기에 저장됩니다. 서버 동기화는 준비 중입니다. -

-
+ {notifQ.isLoading ? ( + + ) : notifQ.isError ? ( + notifQ.refetch()} /> + ) : ( +
+ {NOTIFY_ROWS.map(({ key, label }) => ( + + ))} +

+ 알림 설정은 서버에 저장되어 모든 기기에 동일하게 적용됩니다. +

+
+ )} )} diff --git a/src/frontend/src/screens/work/myPageApi.ts b/src/frontend/src/screens/work/myPageApi.ts new file mode 100644 index 0000000..5996e19 --- /dev/null +++ b/src/frontend/src/screens/work/myPageApi.ts @@ -0,0 +1,51 @@ +/* + * 마이페이지(SCR-48) 전용 API — 프로필 수정·비밀번호 변경·알림 설정(GAP G-08). + * endpoints.ts 를 건드리지 않기 위해 MyPage 전용으로 분리한다. 공유 client(api) 만 재사용한다. + * 백엔드 계약: com.zioinfo.kintex.auth.AccountSettingsController (/api/auth/me/*). + */ +import { api } from '../../api/client'; + +/** GET /api/auth/me 순증 프로필(백엔드 MeResponse). endpoints 의 KintexPrincipal 상위집합. */ +export interface MeProfile { + userId: string; + displayName: string; + hallManager: boolean; + roleCode?: string | null; + tenantId?: string | null; + email?: string | null; + phone?: string | null; + deptId?: string | null; + photoUrl?: string | null; +} + +/** 알림 설정(백엔드 NotificationPrefsDto). */ +export interface NotificationPrefs { + notifyDeadline: boolean; + notifyApproval: boolean; + notifyPayment: boolean; + emailEnabled: boolean; +} + +export interface ProfileUpdateBody { + displayName: string; + phone: string; // 빈 문자열 = 연락처 삭제 +} + +export interface ChangePasswordBody { + currentPassword: string; + newPassword: string; +} + +export const myPageApi = { + /** 현재 사용자 프로필(백엔드 MeResponse 상위집합 — email·phone 포함). endpoints.authApi.me()와 동일 경로. */ + me: () => api.get('/api/auth/me'), + /** 프로필 수정(displayName·phone 화이트리스트) → 갱신된 프로필. */ + updateProfile: (body: ProfileUpdateBody) => api.put('/api/auth/me', body), + /** 비밀번호 변경(현재 비밀번호 검증). */ + changePassword: (body: ChangePasswordBody) => api.post('/api/auth/me/password', body), + /** 알림 설정 조회. */ + getNotificationPrefs: () => api.get('/api/auth/me/notification-prefs'), + /** 알림 설정 저장(멱등). */ + saveNotificationPrefs: (body: NotificationPrefs) => + api.put('/api/auth/me/notification-prefs', body), +};