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),
+};