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 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 06:26:50 +09:00
parent cf431a8809
commit a836b79852
11 changed files with 607 additions and 59 deletions

View File

@ -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).
* <ul>
* <li>{@code PUT /api/auth/me} 프로필 수정(displayName·phone 화이트리스트) {@link MeResponse}</li>
* <li>{@code POST /api/auth/me/password} 비밀번호 변경(현재 비밀번호 검증)</li>
* <li>{@code GET /api/auth/me/notification-prefs} 알림 설정 조회</li>
* <li>{@code PUT /api/auth/me/notification-prefs} 알림 설정 저장(멱등)</li>
* </ul>
* 대상은 항상 인증 principal 본인이다(요청 본문/경로로 userId 미수용). {@code GET /api/auth/me}
* 기존 {@link AuthController} 소관(회귀 0) 여기서는 <b>변경(mutation)</b> 담당한다.
*/
@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<MeResponse> 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<Void> changePassword(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody ChangePasswordRequest req) {
guard.require(principal);
service.changePassword(principal, req);
return ApiResponse.ok(null);
}
/** 알림 설정 조회 — 미설정 시 서버 기본값. */
@GetMapping("/notification-prefs")
public ApiResponse<NotificationPrefsDto> 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<NotificationPrefsDto> saveNotificationPrefs(
@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody NotificationPrefsDto req) {
guard.require(principal);
return ApiResponse.ok(service.saveNotificationPrefs(principal, req));
}
}

View File

@ -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).
*
* <p>기존 {@code AuthServiceImpl}(로그인·me)·2FA 로직은 <b>교체하지 않고</b> 순증한다.
* 모든 변경은 <b>본인({@code principal.userId()})</b> 대해서만 수행하며 대상 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<String, Object> 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<String, Object> 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));
}
}

View File

@ -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
) {
}

View File

@ -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);
}
}

View File

@ -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). <b>서버 화이트리스트</b>: 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
) {
}

View File

@ -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<String, Object> 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);
}

View File

@ -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);
}

View File

@ -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 — 인앱 알림만)';

View File

@ -74,4 +74,29 @@
WHERE id = #{userId}
</update>
<!-- 프로필 수정(본인만) — 화이트리스트 컬럼만. 빈 phone 은 NULL 저장(연락처 삭제). -->
<update id="updateProfile">
UPDATE app_user
SET display_name = #{displayName},
phone = #{phone},
updated_at = now()
WHERE id = #{userId}
</update>
<!-- 비밀번호 검증용 해시 조회(현재 비밀번호 대조 전용) — 응답 노출 금지. -->
<select id="findPasswordHash" resultType="string">
SELECT password_hash
FROM app_user
WHERE id = #{userId}
AND status = 'ACTIVE'
</select>
<!-- 비밀번호 해시 갱신(본인 변경) — 서비스에서 현재 비밀번호 검증 후에만 호출. -->
<update id="updatePassword">
UPDATE app_user
SET password_hash = #{passwordHash},
updated_at = now()
WHERE id = #{userId}
</update>
</mapper>

View File

@ -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<Tab>('profile');
const [prefs, setPrefs] = useState<Prefs>(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 (
<div className="kx-page">
@ -106,30 +179,65 @@ export function MyPage() {
) : meQ.isError ? (
<ErrorState onRetry={() => meQ.refetch()} />
) : (
<div style={{ display: 'flex', gap: 20, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="kx-my__avatar">{displayName[0] ?? '·'}</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<strong style={{ fontSize: 18 }}>{displayName}</strong>
<span className="kx-list-table__muted"> ID: {meQ.data?.userId}</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{meQ.data?.hallManager && <span className="kx-pill kx-pill--info"></span>}
{roles.map((r, i) => (
<RoleBadge key={i} role={r} />
))}
<>
<div style={{ display: 'flex', gap: 20, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="kx-my__avatar">{displayName[0] ?? '·'}</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span className="kx-list-table__muted"> ID: {meQ.data?.userId}</span>
{meQ.data?.email && (
<span className="kx-list-table__muted">{meQ.data.email}</span>
)}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{meQ.data?.hallManager && <span className="kx-pill kx-pill--info"></span>}
{roles.map((r, i) => (
<RoleBadge key={i} role={r} />
))}
</div>
<span className="kx-list-table__muted"> {workspaces.length}</span>
</div>
<span className="kx-list-table__muted">
{workspaces.length}
</span>
</div>
</div>
<div className="kx-formgrid" style={{ marginTop: 16 }}>
<label className="kx-field">
<span className="kx-label"></span>
<input
className="kx-input"
value={name}
maxLength={120}
onChange={(e) => setName(e.target.value)}
placeholder="표시 이름"
/>
</label>
<label className="kx-field">
<span className="kx-label"></span>
<input
className="kx-input"
value={phone}
maxLength={40}
onChange={(e) => setPhone(e.target.value)}
placeholder="예: 010-1234-5678"
inputMode="tel"
/>
</label>
<label className="kx-field">
<span className="kx-label">( )</span>
<input className="kx-input" value={meQ.data?.email ?? ''} disabled readOnly />
</label>
</div>
<div style={{ marginTop: 12 }}>
<Button
onClick={() => profileMut.mutate()}
disabled={!dirty || !name.trim() || profileMut.isPending}
>
{profileMut.isPending ? '저장 중…' : '프로필 저장'}
</Button>
</div>
</>
)}
<p className="kx-list-table__muted" style={{ marginTop: 12, fontSize: 12 }}>
.
</p>
</>
)}
{/* 보안 (2FA) */}
{/* 보안 (2FA + 비밀번호 변경) */}
{tab === 'security' && (
<>
<div className="kx-card__head">
@ -162,6 +270,50 @@ export function MyPage() {
</div>
</div>
)}
<div className="kx-card__head" style={{ marginTop: 24 }}>
<h2><IconShieldCheck className="kx-title-ic" size={18} /> </h2>
</div>
<div className="kx-formgrid">
<label className="kx-field">
<span className="kx-label"> </span>
<input
className="kx-input"
type="password"
value={curPw}
autoComplete="current-password"
onChange={(e) => setCurPw(e.target.value)}
/>
</label>
<label className="kx-field">
<span className="kx-label"> (8 )</span>
<input
className="kx-input"
type="password"
value={newPw}
autoComplete="new-password"
onChange={(e) => setNewPw(e.target.value)}
/>
</label>
<label className="kx-field">
<span className="kx-label"> </span>
<input
className="kx-input"
type="password"
value={confirmPw}
autoComplete="new-password"
onChange={(e) => setConfirmPw(e.target.value)}
/>
</label>
</div>
<div style={{ marginTop: 12 }}>
<Button
onClick={submitPassword}
disabled={!curPw || !newPw || !confirmPw || pwMut.isPending}
>
{pwMut.isPending ? '변경 중…' : '비밀번호 변경'}
</Button>
</div>
</>
)}
@ -171,27 +323,28 @@ export function MyPage() {
<div className="kx-card__head">
<h2><IconBell className="kx-title-ic" size={18} /> </h2>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(
[
['notifyDeadline', '마감 D-데이 알림'],
['notifyApproval', '승인·검수 알림'],
['notifyPayment', '결제·정산 알림'],
] as const
).map(([key, label]) => (
<label className="kx-switch" key={key}>
<input
type="checkbox"
checked={prefs[key]}
onChange={(e) => savePrefs({ ...prefs, [key]: e.target.checked })}
/>
<span>{label}</span>
</label>
))}
<p className="kx-list-table__muted" style={{ fontSize: 12 }}>
. .
</p>
</div>
{notifQ.isLoading ? (
<Skeleton height={120} />
) : notifQ.isError ? (
<ErrorState onRetry={() => notifQ.refetch()} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{NOTIFY_ROWS.map(({ key, label }) => (
<label className="kx-switch" key={key}>
<input
type="checkbox"
checked={notifQ.data?.[key] ?? false}
disabled={notifMut.isPending}
onChange={(e) => toggleNotify(key, e.target.checked)}
/>
<span>{label}</span>
</label>
))}
<p className="kx-list-table__muted" style={{ fontSize: 12 }}>
.
</p>
</div>
)}
</>
)}

View File

@ -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<MeProfile>('/api/auth/me'),
/** 프로필 수정(displayName·phone 화이트리스트) → 갱신된 프로필. */
updateProfile: (body: ProfileUpdateBody) => api.put<MeProfile>('/api/auth/me', body),
/** 비밀번호 변경(현재 비밀번호 검증). */
changePassword: (body: ChangePasswordBody) => api.post<void>('/api/auth/me/password', body),
/** 알림 설정 조회. */
getNotificationPrefs: () => api.get<NotificationPrefs>('/api/auth/me/notification-prefs'),
/** 알림 설정 저장(멱등). */
saveNotificationPrefs: (body: NotificationPrefs) =>
api.put<NotificationPrefs>('/api/auth/me/notification-prefs', body),
};