- AuthenticatedLayout: FCM 통합 및 레이아웃 개선 - logout: 로그아웃 시 FCM 토큰 정리 로직 추가 - AccountInfoManagement: 계정 정보 관리 UI 개선 - not-found 페이지 스타일 개선 - 환경변수 예시 파일 업데이트
535 lines
19 KiB
TypeScript
535 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
User,
|
|
Upload,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
|
|
import { ACCOUNT_STATUS_LABELS } from './types';
|
|
import { withdrawAccount, suspendTenant, updateAgreements, uploadProfileImage } from './actions';
|
|
|
|
// ===== Props 인터페이스 =====
|
|
interface AccountInfoClientProps {
|
|
initialAccountInfo: AccountInfo;
|
|
initialTermsAgreements: TermsAgreement[];
|
|
initialMarketingConsent: MarketingConsent;
|
|
error?: string;
|
|
}
|
|
|
|
export function AccountInfoClient({
|
|
initialAccountInfo,
|
|
initialTermsAgreements,
|
|
initialMarketingConsent,
|
|
error,
|
|
}: AccountInfoClientProps) {
|
|
const router = useRouter();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// ===== 상태 관리 =====
|
|
const [accountInfo] = useState<AccountInfo>(initialAccountInfo);
|
|
const [termsAgreements] = useState<TermsAgreement[]>(initialTermsAgreements);
|
|
const [marketingConsent, setMarketingConsent] = useState<MarketingConsent>(initialMarketingConsent);
|
|
const [profileImage, setProfileImage] = useState<string | undefined>(initialAccountInfo.profileImage);
|
|
const [isSavingMarketing, setIsSavingMarketing] = useState(false);
|
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
|
|
|
// 다이얼로그 상태
|
|
const [showWithdrawDialog, setShowWithdrawDialog] = useState(false);
|
|
const [showSuspendDialog, setShowSuspendDialog] = useState(false);
|
|
const [isWithdrawing, setIsWithdrawing] = useState(false);
|
|
const [isSuspending, setIsSuspending] = useState(false);
|
|
const [withdrawPassword, setWithdrawPassword] = useState('');
|
|
|
|
// 에러 표시
|
|
useEffect(() => {
|
|
if (error) {
|
|
toast.error(error);
|
|
}
|
|
}, [error]);
|
|
|
|
// ===== 버튼 활성화 조건 =====
|
|
const canWithdraw = !accountInfo.isTenantMaster; // 테넌트 마스터가 아닌 경우만
|
|
const canSuspend = accountInfo.isTenantMaster; // 테넌트 마스터인 경우만
|
|
|
|
// ===== 핸들러 =====
|
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// 파일 크기 체크 (10MB)
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
|
return;
|
|
}
|
|
|
|
// 파일 타입 체크
|
|
const validTypes = ['image/png', 'image/jpeg', 'image/gif'];
|
|
if (!validTypes.includes(file.type)) {
|
|
toast.error('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
|
|
return;
|
|
}
|
|
|
|
// 미리보기 생성 (낙관적 업데이트)
|
|
const previousImage = profileImage;
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
setProfileImage(event.target?.result as string);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// API 호출
|
|
setIsUploadingImage(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const result = await uploadProfileImage(formData);
|
|
if (result.success) {
|
|
toast.success('프로필 이미지가 업로드되었습니다.');
|
|
} else {
|
|
// 실패 시 롤백
|
|
setProfileImage(previousImage);
|
|
toast.error(result.error || '이미지 업로드에 실패했습니다.');
|
|
}
|
|
} catch {
|
|
// 에러 시 롤백
|
|
setProfileImage(previousImage);
|
|
toast.error('서버 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsUploadingImage(false);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRemoveImage = () => {
|
|
setProfileImage(undefined);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handlePasswordChange = () => {
|
|
// 비밀번호 설정 화면으로 이동
|
|
router.push('/ko/settings/account-info/change-password');
|
|
};
|
|
|
|
const handleWithdraw = () => {
|
|
setShowWithdrawDialog(true);
|
|
};
|
|
|
|
const handleConfirmWithdraw = async () => {
|
|
if (!withdrawPassword) {
|
|
toast.error('비밀번호를 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setIsWithdrawing(true);
|
|
try {
|
|
const result = await withdrawAccount(withdrawPassword);
|
|
if (result.success) {
|
|
toast.success('계정이 탈퇴되었습니다.');
|
|
setShowWithdrawDialog(false);
|
|
setWithdrawPassword('');
|
|
// 로그아웃 및 로그인 페이지로 이동
|
|
router.push('/ko/login');
|
|
} else {
|
|
toast.error(result.error || '계정 탈퇴에 실패했습니다.');
|
|
}
|
|
} catch {
|
|
toast.error('서버 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsWithdrawing(false);
|
|
}
|
|
};
|
|
|
|
const handleSuspend = () => {
|
|
setShowSuspendDialog(true);
|
|
};
|
|
|
|
const handleConfirmSuspend = async () => {
|
|
setIsSuspending(true);
|
|
try {
|
|
const result = await suspendTenant();
|
|
if (result.success) {
|
|
toast.success('테넌트 사용이 중지되었습니다.');
|
|
setShowSuspendDialog(false);
|
|
} else {
|
|
toast.error(result.error || '사용 중지에 실패했습니다.');
|
|
}
|
|
} catch {
|
|
toast.error('서버 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsSuspending(false);
|
|
}
|
|
};
|
|
|
|
const handleEdit = () => {
|
|
// 수정 모드로 전환 또는 별도 수정 페이지로 이동
|
|
router.push('/ko/settings/account-info?mode=edit');
|
|
};
|
|
|
|
const handleMarketingChange = async (type: 'email' | 'sms', checked: boolean) => {
|
|
const now = new Date().toLocaleString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).replace(/\. /g, '-').replace('.', '');
|
|
|
|
// 낙관적 업데이트 (UI 먼저 변경)
|
|
setMarketingConsent(prev => ({
|
|
...prev,
|
|
[type]: {
|
|
agreed: checked,
|
|
...(checked ? { agreedAt: now } : { withdrawnAt: now }),
|
|
},
|
|
}));
|
|
|
|
// API 호출
|
|
setIsSavingMarketing(true);
|
|
try {
|
|
const result = await updateAgreements([{ type, agreed: checked }]);
|
|
if (result.success) {
|
|
toast.success(checked ? '수신 동의되었습니다.' : '수신 동의가 철회되었습니다.');
|
|
} else {
|
|
// 실패 시 롤백
|
|
setMarketingConsent(prev => ({
|
|
...prev,
|
|
[type]: {
|
|
agreed: !checked,
|
|
...(checked ? { withdrawnAt: now } : { agreedAt: now }),
|
|
},
|
|
}));
|
|
toast.error(result.error || '변경에 실패했습니다.');
|
|
}
|
|
} catch {
|
|
// 에러 시 롤백
|
|
setMarketingConsent(prev => ({
|
|
...prev,
|
|
[type]: {
|
|
agreed: !checked,
|
|
...(checked ? { withdrawnAt: now } : { agreedAt: now }),
|
|
},
|
|
}));
|
|
toast.error('서버 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsSavingMarketing(false);
|
|
}
|
|
};
|
|
|
|
// ===== 헤더 액션 버튼 =====
|
|
const headerActions = (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleWithdraw}
|
|
disabled={!canWithdraw}
|
|
className={!canWithdraw ? 'opacity-50 cursor-not-allowed' : 'border-red-300 text-red-600 hover:bg-red-50'}
|
|
>
|
|
탈퇴
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleSuspend}
|
|
disabled={!canSuspend}
|
|
className={!canSuspend ? 'opacity-50 cursor-not-allowed' : 'border-orange-300 text-orange-600 hover:bg-orange-50'}
|
|
>
|
|
사용중지
|
|
</Button>
|
|
<Button onClick={handleEdit}>
|
|
수정
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageLayout>
|
|
<PageHeader
|
|
title="계정정보"
|
|
description="계정 정보를 관리합니다"
|
|
icon={User}
|
|
actions={headerActions}
|
|
/>
|
|
|
|
<div className="space-y-6">
|
|
{/* 계정 정보 섹션 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">계정 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* 프로필 사진 */}
|
|
<div className="space-y-2">
|
|
<Label>프로필 사진</Label>
|
|
<div className="flex items-start gap-4">
|
|
<div className="relative w-[250px] h-[250px] border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center bg-gray-50 overflow-hidden">
|
|
{profileImage ? (
|
|
<>
|
|
<img
|
|
src={profileImage}
|
|
alt="프로필"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleRemoveImage}
|
|
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md hover:bg-gray-100"
|
|
>
|
|
<X className="w-4 h-4 text-gray-600" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div className="text-center">
|
|
<Upload className="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
|
<span className="text-sm text-gray-500">IMG</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/gif"
|
|
onChange={handleImageUpload}
|
|
className="hidden"
|
|
disabled={isUploadingImage}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isUploadingImage}
|
|
>
|
|
{isUploadingImage ? '업로드 중...' : '이미지 업로드'}
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">
|
|
1250 X 250px, 10MB 이하의 PNG, JPEG, GIF
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 아이디 & 비밀번호 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<Label>아이디</Label>
|
|
<Input
|
|
value={accountInfo.email}
|
|
disabled
|
|
className="bg-gray-50"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>비밀번호</Label>
|
|
<div className="flex items-center">
|
|
<Button
|
|
variant="default"
|
|
size="default"
|
|
onClick={handlePasswordChange}
|
|
>
|
|
변경
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 권한 & 상태 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<Label>권한</Label>
|
|
<Input
|
|
value={accountInfo.role}
|
|
disabled
|
|
className="bg-gray-50"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>상태</Label>
|
|
<Input
|
|
value={ACCOUNT_STATUS_LABELS[accountInfo.status]}
|
|
disabled
|
|
className="bg-gray-50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 약관 동의 정보 섹션 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">약관 동의 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* 필수 약관 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{termsAgreements.map((term, index) => (
|
|
<div key={index} className="space-y-2">
|
|
<Label className="flex items-center gap-1">
|
|
<Badge variant="secondary" className="text-xs">
|
|
필수
|
|
</Badge>
|
|
{term.label}
|
|
</Label>
|
|
<div className="text-sm text-muted-foreground">
|
|
동의일시 {term.agreedAt}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 선택 약관 - 마케팅 정보 수신 동의 */}
|
|
<div className="space-y-4">
|
|
<Label className="flex items-center gap-1">
|
|
<Badge variant="outline" className="text-xs">
|
|
선택
|
|
</Badge>
|
|
마케팅 정보 수신 동의
|
|
</Label>
|
|
|
|
<div className="space-y-3 pl-4">
|
|
{/* 이메일 수신 동의 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="email-consent"
|
|
checked={marketingConsent.email.agreed}
|
|
onCheckedChange={(checked) => handleMarketingChange('email', checked as boolean)}
|
|
disabled={isSavingMarketing}
|
|
/>
|
|
<Label htmlFor="email-consent" className="text-sm font-normal cursor-pointer">
|
|
이메일 수신을 동의합니다.
|
|
</Label>
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">
|
|
동의일시 {marketingConsent.email.agreedAt || '-'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* SMS 수신 동의 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="sms-consent"
|
|
checked={marketingConsent.sms.agreed}
|
|
onCheckedChange={(checked) => handleMarketingChange('sms', checked as boolean)}
|
|
disabled={isSavingMarketing}
|
|
/>
|
|
<Label htmlFor="sms-consent" className="text-sm font-normal cursor-pointer">
|
|
SMS 수신을 동의합니다.
|
|
</Label>
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">
|
|
{marketingConsent.sms.agreed
|
|
? `동의일시 ${marketingConsent.sms.withdrawnAt || '-'}`
|
|
: `동의철회일시 ${marketingConsent.sms.withdrawnAt || '-'}`
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</PageLayout>
|
|
|
|
{/* 탈퇴 확인 다이얼로그 */}
|
|
<AlertDialog open={showWithdrawDialog} onOpenChange={(open) => {
|
|
setShowWithdrawDialog(open);
|
|
if (!open) setWithdrawPassword('');
|
|
}}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>계정 탈퇴</AlertDialogTitle>
|
|
<AlertDialogDescription asChild>
|
|
<div className="space-y-4">
|
|
<p>
|
|
정말 탈퇴하시겠습니까?
|
|
<br />
|
|
<span className="text-muted-foreground text-sm">
|
|
모든 테넌트에서 탈퇴 처리되며, SAM 서비스에서 완전히 탈퇴됩니다.
|
|
</span>
|
|
</p>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="withdraw-password">비밀번호 확인</Label>
|
|
<Input
|
|
id="withdraw-password"
|
|
type="password"
|
|
placeholder="비밀번호를 입력하세요"
|
|
value={withdrawPassword}
|
|
onChange={(e) => setWithdrawPassword(e.target.value)}
|
|
disabled={isWithdrawing}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isWithdrawing}>취소</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleConfirmWithdraw}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
disabled={isWithdrawing || !withdrawPassword}
|
|
>
|
|
{isWithdrawing ? '처리 중...' : '확인'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* 사용중지 확인 다이얼로그 */}
|
|
<AlertDialog open={showSuspendDialog} onOpenChange={setShowSuspendDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>계정 사용중지</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
정말 사용중지하시겠습니까?
|
|
<br />
|
|
<span className="text-muted-foreground text-sm">
|
|
해당 테넌트의 사용이 중지됩니다.
|
|
</span>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isSuspending}>취소</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleConfirmSuspend}
|
|
className="bg-orange-600 hover:bg-orange-700"
|
|
disabled={isSuspending}
|
|
>
|
|
{isSuspending ? '처리 중...' : '확인'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|