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:
parent
cf431a8809
commit
a836b79852
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 — 인앱 알림만)';
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
51
src/frontend/src/screens/work/myPageApi.ts
Normal file
51
src/frontend/src/screens/work/myPageApi.ts
Normal 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),
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user