refactor(WEB): 레이아웃 및 설정 관리 개선
- AuthenticatedLayout: FCM 통합 및 레이아웃 개선 - logout: 로그아웃 시 FCM 토큰 정리 로직 추가 - AccountInfoManagement: 계정 정보 관리 UI 개선 - not-found 페이지 스타일 개선 - 환경변수 예시 파일 업데이트
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# ==============================================
|
||||
# API Configuration
|
||||
# ==============================================
|
||||
NEXT_PUBLIC_API_URL=https://api.5130.co.kr
|
||||
API_URL=https://api.5130.co.kr
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ==============================================
|
||||
# API Configuration
|
||||
# ==============================================
|
||||
NEXT_PUBLIC_API_URL=https://api.codebridge-x.com
|
||||
API_URL=https://api.codebridge-x.com
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
NEXT_PUBLIC_FRONTEND_URL=https://dev.codebridge-x.com
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import AuthenticatedLayout from '@/layouts/AuthenticatedLayout';
|
||||
import { RootProvider } from '@/contexts/RootProvider';
|
||||
import { ApiErrorProvider } from '@/contexts/ApiErrorContext';
|
||||
import { FCMProvider } from '@/contexts/FCMProvider';
|
||||
|
||||
/**
|
||||
* Protected Layout
|
||||
@@ -13,6 +14,7 @@ import { ApiErrorProvider } from '@/contexts/ApiErrorContext';
|
||||
* - Apply common layout (sidebar, header) to all protected pages
|
||||
* - Provide global context (RootProvider)
|
||||
* - Provide API error handling context (ApiErrorProvider)
|
||||
* - Initialize FCM push notifications (Capacitor native apps)
|
||||
* - Prevent browser back button cache issues
|
||||
* - Centralized protection for all routes under (protected)
|
||||
*
|
||||
@@ -35,9 +37,9 @@ export default function ProtectedLayout({
|
||||
// 🚨 ApiErrorProvider: Server Action 401 에러 시 자동 로그인 리다이렉트
|
||||
return (
|
||||
<RootProvider>
|
||||
<ApiErrorProvider>
|
||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||
</ApiErrorProvider>
|
||||
<ApiErrorProvider>
|
||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||
</ApiErrorProvider>
|
||||
</RootProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SearchX, Home, ArrowLeft, Map } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -12,6 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
* - 보호된 경로 내에서 404 발생 시 표시
|
||||
*/
|
||||
export default function ProtectedNotFoundPage() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[calc(100vh-200px)] p-4">
|
||||
<Card className="w-full max-w-2xl border border-border/20 bg-card/50 backdrop-blur">
|
||||
@@ -56,13 +60,11 @@ export default function ProtectedNotFoundPage() {
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="flex-1 rounded-xl"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Link href="javascript:history.back()">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전 페이지
|
||||
</Link>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전 페이지
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
import { AccountInfoClient } from '@/components/settings/AccountInfoManagement';
|
||||
import { getAccountInfo } from '@/components/settings/AccountInfoManagement/actions';
|
||||
|
||||
export default function AccountInfoPage() {
|
||||
return <AccountInfoClient />;
|
||||
}
|
||||
export default async function AccountInfoPage() {
|
||||
const result = await getAccountInfo();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
// 실패 시 빈 데이터로 렌더링 (클라이언트에서 에러 처리)
|
||||
return (
|
||||
<AccountInfoClient
|
||||
initialAccountInfo={{
|
||||
id: '',
|
||||
email: '',
|
||||
profileImage: undefined,
|
||||
role: '',
|
||||
status: 'active',
|
||||
isTenantMaster: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}}
|
||||
initialTermsAgreements={[]}
|
||||
initialMarketingConsent={{
|
||||
email: { agreed: false },
|
||||
sms: { agreed: false },
|
||||
}}
|
||||
error={result.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountInfoClient
|
||||
initialAccountInfo={result.data.accountInfo}
|
||||
initialTermsAgreements={result.data.termsAgreements}
|
||||
initialMarketingConsent={result.data.marketingConsent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SearchX, Home, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -11,10 +14,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
* - notFound() 함수 호출 시
|
||||
*
|
||||
* 특징:
|
||||
* - 서버 컴포넌트 (metadata 지원 가능)
|
||||
* - 클라이언트 컴포넌트 (router.back() 사용)
|
||||
* - 다국어 지원 (next-intl)
|
||||
*/
|
||||
export default function NotFoundPage() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<Card className="w-full max-w-2xl border border-border/20 bg-card/80 backdrop-blur-xl shadow-2xl">
|
||||
@@ -54,13 +58,11 @@ export default function NotFoundPage() {
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="flex-1 rounded-xl"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Link href="javascript:history.back()">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전 페이지
|
||||
</Link>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전 페이지
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
User,
|
||||
@@ -28,89 +28,100 @@ 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 } from './actions';
|
||||
import { withdrawAccount, suspendTenant, updateAgreements, uploadProfileImage } from './actions';
|
||||
|
||||
// ===== Mock 데이터 =====
|
||||
const mockAccountInfo: AccountInfo = {
|
||||
id: 'user-1',
|
||||
email: 'abc@email.com',
|
||||
profileImage: undefined,
|
||||
role: '권한명',
|
||||
status: 'active',
|
||||
isTenantMaster: false, // true로 변경하면 사용중지 버튼 활성화
|
||||
createdAt: '2025-12-12T12:12:00.000Z',
|
||||
updatedAt: '2025-12-12T12:12:00.000Z',
|
||||
};
|
||||
// ===== Props 인터페이스 =====
|
||||
interface AccountInfoClientProps {
|
||||
initialAccountInfo: AccountInfo;
|
||||
initialTermsAgreements: TermsAgreement[];
|
||||
initialMarketingConsent: MarketingConsent;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const mockTermsAgreements: TermsAgreement[] = [
|
||||
{
|
||||
type: 'required',
|
||||
label: '서비스 이용약관 동의',
|
||||
agreed: true,
|
||||
agreedAt: '2025-12-12 12:12',
|
||||
},
|
||||
{
|
||||
type: 'required',
|
||||
label: '개인정보 취급방침',
|
||||
agreed: true,
|
||||
agreedAt: '2025-12-12 12:12',
|
||||
},
|
||||
];
|
||||
|
||||
const mockMarketingConsent: MarketingConsent = {
|
||||
email: {
|
||||
agreed: true,
|
||||
agreedAt: '2025-12-12 12:12',
|
||||
},
|
||||
sms: {
|
||||
agreed: false,
|
||||
withdrawnAt: '2025-12-12 12:12',
|
||||
},
|
||||
};
|
||||
|
||||
export function AccountInfoClient() {
|
||||
export function AccountInfoClient({
|
||||
initialAccountInfo,
|
||||
initialTermsAgreements,
|
||||
initialMarketingConsent,
|
||||
error,
|
||||
}: AccountInfoClientProps) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [accountInfo] = useState<AccountInfo>(mockAccountInfo);
|
||||
const [termsAgreements] = useState<TermsAgreement[]>(mockTermsAgreements);
|
||||
const [marketingConsent, setMarketingConsent] = useState<MarketingConsent>(mockMarketingConsent);
|
||||
const [profileImage, setProfileImage] = useState<string | undefined>(accountInfo.profileImage);
|
||||
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 = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// 파일 크기 체크 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
|
||||
// 파일 타입 체크
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
// 파일 크기 체크 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 미리보기 생성
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setProfileImage(event.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
// 파일 타입 체크
|
||||
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 = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,12 +142,18 @@ export function AccountInfoClient() {
|
||||
};
|
||||
|
||||
const handleConfirmWithdraw = async () => {
|
||||
if (!withdrawPassword) {
|
||||
toast.error('비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsWithdrawing(true);
|
||||
try {
|
||||
const result = await withdrawAccount();
|
||||
const result = await withdrawAccount(withdrawPassword);
|
||||
if (result.success) {
|
||||
toast.success('계정이 탈퇴되었습니다.');
|
||||
setShowWithdrawDialog(false);
|
||||
setWithdrawPassword('');
|
||||
// 로그아웃 및 로그인 페이지로 이동
|
||||
router.push('/ko/login');
|
||||
} else {
|
||||
@@ -175,7 +192,7 @@ export function AccountInfoClient() {
|
||||
router.push('/ko/settings/account-info?mode=edit');
|
||||
};
|
||||
|
||||
const handleMarketingChange = (type: 'email' | 'sms', checked: boolean) => {
|
||||
const handleMarketingChange = async (type: 'email' | 'sms', checked: boolean) => {
|
||||
const now = new Date().toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@@ -184,6 +201,7 @@ export function AccountInfoClient() {
|
||||
minute: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
// 낙관적 업데이트 (UI 먼저 변경)
|
||||
setMarketingConsent(prev => ({
|
||||
...prev,
|
||||
[type]: {
|
||||
@@ -191,6 +209,37 @@ export function AccountInfoClient() {
|
||||
...(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);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 헤더 액션 버튼 =====
|
||||
@@ -269,14 +318,16 @@ export function AccountInfoClient() {
|
||||
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
|
||||
@@ -371,6 +422,7 @@ export function AccountInfoClient() {
|
||||
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">
|
||||
이메일 수신을 동의합니다.
|
||||
@@ -388,6 +440,7 @@ export function AccountInfoClient() {
|
||||
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 수신을 동의합니다.
|
||||
@@ -408,16 +461,34 @@ export function AccountInfoClient() {
|
||||
</PageLayout>
|
||||
|
||||
{/* 탈퇴 확인 다이얼로그 */}
|
||||
<AlertDialog open={showWithdrawDialog} onOpenChange={setShowWithdrawDialog}>
|
||||
<AlertDialog open={showWithdrawDialog} onOpenChange={(open) => {
|
||||
setShowWithdrawDialog(open);
|
||||
if (!open) setWithdrawPassword('');
|
||||
}}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계정 탈퇴</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말 탈퇴하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
모든 테넌트에서 탈퇴 처리되며, SAM 서비스에서 완전히 탈퇴됩니다.
|
||||
</span>
|
||||
<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>
|
||||
@@ -425,7 +496,7 @@ export function AccountInfoClient() {
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmWithdraw}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={isWithdrawing}
|
||||
disabled={isWithdrawing || !withdrawPassword}
|
||||
>
|
||||
{isWithdrawing ? '처리 중...' : '확인'}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -139,22 +139,39 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, '');
|
||||
|
||||
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
|
||||
// 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭 (예: /hr/attendance는 /hr/attendance-management와 매칭되면 안됨)
|
||||
const isPathMatch = (menuPath: string, currentPath: string): boolean => {
|
||||
if (currentPath === menuPath) return true;
|
||||
// 하위 경로 확인: /menu/path/subpath 형태만 매칭 (슬래시로 구분)
|
||||
return currentPath.startsWith(menuPath + '/');
|
||||
};
|
||||
|
||||
const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => {
|
||||
// 모든 매칭 가능한 메뉴 수집 (가장 긴 경로가 가장 구체적)
|
||||
const matches: { menuId: string; parentId?: string; pathLength: number }[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
// 서브메뉴가 있으면 먼저 확인 (더 구체적인 경로 우선)
|
||||
// 서브메뉴 확인
|
||||
if (item.children && item.children.length > 0) {
|
||||
for (const child of item.children) {
|
||||
if (child.path && normalizedPath.startsWith(child.path)) {
|
||||
return { menuId: child.id, parentId: item.id };
|
||||
if (child.path && isPathMatch(child.path, normalizedPath)) {
|
||||
matches.push({ menuId: child.id, parentId: item.id, pathLength: child.path.length });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서브메뉴에서 매칭되지 않으면 현재 메뉴 확인
|
||||
if (item.path && normalizedPath.startsWith(item.path)) {
|
||||
return { menuId: item.id };
|
||||
// 메인 메뉴 확인
|
||||
if (item.path && item.path !== '#' && isPathMatch(item.path, normalizedPath)) {
|
||||
matches.push({ menuId: item.id, pathLength: item.path.length });
|
||||
}
|
||||
}
|
||||
|
||||
// 가장 긴 경로(가장 구체적인 매칭) 반환
|
||||
if (matches.length > 0) {
|
||||
matches.sort((a, b) => b.pathLength - a.pathLength);
|
||||
return { menuId: matches[0].menuId, parentId: matches[0].parentId };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 1. Zustand 스토어 초기화 (메모리 캐시)
|
||||
* 2. sessionStorage 캐시 삭제 (page_config_*, mes-*)
|
||||
* 3. localStorage 사용자 데이터 삭제 (mes-currentUser, mes-users)
|
||||
* 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
||||
* 4. FCM 토큰 해제 (Capacitor 네이티브 앱)
|
||||
* 5. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
||||
*
|
||||
* @see claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md
|
||||
*/
|
||||
@@ -13,6 +14,8 @@
|
||||
import { useMasterDataStore } from '@/stores/masterDataStore';
|
||||
import { useItemStore } from '@/stores/itemStore';
|
||||
|
||||
// FCM은 Capacitor 환경에서만 사용 (동적 import로 웹 빌드 에러 방지)
|
||||
|
||||
// ===== 캐시 삭제 대상 Prefix =====
|
||||
|
||||
const SESSION_STORAGE_PREFIXES = [
|
||||
@@ -144,7 +147,8 @@ export async function callLogoutAPI(): Promise<boolean> {
|
||||
* 1. Zustand 스토어 초기화 (즉시 UI 반영)
|
||||
* 2. sessionStorage 캐시 삭제
|
||||
* 3. localStorage 사용자 데이터 삭제
|
||||
* 4. 서버 로그아웃 API 호출
|
||||
* 4. FCM 토큰 해제 (Capacitor 네이티브 앱)
|
||||
* 5. 서버 로그아웃 API 호출
|
||||
*
|
||||
* @param options.skipServerLogout - 서버 로그아웃 생략 여부 (기본: false)
|
||||
* @param options.redirectTo - 로그아웃 후 리다이렉트 경로 (기본: null)
|
||||
@@ -168,14 +172,27 @@ export async function performFullLogout(options?: {
|
||||
// 3. localStorage 사용자 데이터 삭제
|
||||
clearLocalStorageCache();
|
||||
|
||||
// 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
||||
// 4. FCM 토큰 해제 (Capacitor 네이티브 앱에서만 실행)
|
||||
// 동적 import로 @capacitor/core 빌드 에러 방지
|
||||
try {
|
||||
const fcm = await import('@/lib/capacitor/fcm');
|
||||
if (fcm.isCapacitorNative()) {
|
||||
await fcm.unregisterFCMToken();
|
||||
console.log('[Logout] FCM token unregistered');
|
||||
}
|
||||
} catch {
|
||||
// Capacitor 모듈이 없는 환경 (웹) - 무시
|
||||
console.log('[Logout] Skipping FCM (not in native app)');
|
||||
}
|
||||
|
||||
// 5. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
||||
if (!skipServerLogout) {
|
||||
await callLogoutAPI();
|
||||
}
|
||||
|
||||
console.log('[Logout] Full logout completed successfully');
|
||||
|
||||
// 5. 리다이렉트 (선택적)
|
||||
// 6. 리다이렉트 (선택적)
|
||||
if (redirectTo && typeof window !== 'undefined') {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user