init studio admin frontend
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
import { MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { Button, Modal } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { getAdminAppPermissions, isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions';
|
||||
import { listApps } from '../applications/application-api';
|
||||
import type { AppWithComponents } from '../../types/application';
|
||||
import {
|
||||
createAdmin,
|
||||
createAdminSshKey,
|
||||
deleteAdminSshKey,
|
||||
loadAdminRoles,
|
||||
loadAdmins,
|
||||
resetAdminPassword,
|
||||
updateAdminRole,
|
||||
updateAdminStatus,
|
||||
type AdminAccount,
|
||||
type AdminRoleOption
|
||||
} from './operations-api';
|
||||
import { formatDateTime, LoadingModal, StatusBadge, statusTone, useMinimumLoading } from './operation-utils';
|
||||
|
||||
type AdminRow = AdminAccount & {
|
||||
no: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
const emptyAdminForm = {
|
||||
email: '',
|
||||
name: '',
|
||||
role: 'ADMIN',
|
||||
temporaryPassword: ''
|
||||
};
|
||||
|
||||
const fallbackAdminRoles: AdminRoleOption[] = [
|
||||
{ id: 'OWNER', code: 'OWNER', name: 'OWNER', sortOrder: 1, isActive: true },
|
||||
{ id: 'ADMIN', code: 'ADMIN', name: 'ADMIN', sortOrder: 2, isActive: true },
|
||||
{ id: 'VIEWER', code: 'VIEWER', name: 'VIEWER', sortOrder: 3, isActive: true }
|
||||
];
|
||||
|
||||
export function AdminManagementPage() {
|
||||
const { toast } = useToast();
|
||||
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
||||
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [passwordTarget, setPasswordTarget] = useState<{ admin: AdminAccount | null; temporaryPassword: string }>({
|
||||
admin: null,
|
||||
temporaryPassword: ''
|
||||
});
|
||||
const [sshTarget, setSshTarget] = useState({
|
||||
admin: null as AdminAccount | null,
|
||||
label: '',
|
||||
giteaUsername: '',
|
||||
publicKey: ''
|
||||
});
|
||||
const [adminForm, setAdminForm] = useState(emptyAdminForm);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [sshState, setSshState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const [sshError, setSshError] = useState('');
|
||||
const [menuAdmin, setMenuAdmin] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [permissionTarget, setPermissionTarget] = useState<{ admin: AdminAccount | null; appIds: string[] }>({
|
||||
admin: null,
|
||||
appIds: []
|
||||
});
|
||||
const [permissionApps, setPermissionApps] = useState<AppWithComponents[]>([]);
|
||||
const [permissionLoading, setPermissionLoading] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)])
|
||||
.then(([nextAdmins, nextRoles]) => {
|
||||
setAdmins(nextAdmins);
|
||||
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setAdmins([]);
|
||||
setAdminRoles(fallbackAdminRoles);
|
||||
setFailed(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuAdmin) return;
|
||||
|
||||
const close = () => setMenuAdmin(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuAdmin(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuAdmin]);
|
||||
|
||||
const rows = useMemo<AdminRow[]>(
|
||||
() => admins.map((admin, index) => ({ ...admin, no: admins.length - index, state: admin.isActive ? '활성' : '비활성' })),
|
||||
[admins]
|
||||
);
|
||||
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
||||
|
||||
const openSshModal = (admin: AdminAccount) => {
|
||||
setMenuAdmin(null);
|
||||
const account = admin.email.split('@')[0] || 'admin';
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
setSshTarget({ admin, label: `${account}-${random}-key`, giteaUsername: '', publicKey: '' });
|
||||
setSshState('idle');
|
||||
setSshError('');
|
||||
};
|
||||
|
||||
const openPermissionModal = (admin: AdminAccount) => {
|
||||
setMenuAdmin(null);
|
||||
setPermissionTarget({ admin, appIds: isOwnerAdmin(admin) ? [] : getAdminAppPermissions(admin) });
|
||||
setPermissionLoading(true);
|
||||
listApps()
|
||||
.then((apps) => {
|
||||
setPermissionApps(apps);
|
||||
if (isOwnerAdmin(admin)) {
|
||||
setPermissionTarget({ admin, appIds: apps.map((app) => app.id) });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setPermissionApps([]);
|
||||
toast({ type: 'error', title: '앱 목록 조회 실패', message: '앱 권한 설정에 사용할 앱 목록을 불러오지 못했습니다.' });
|
||||
})
|
||||
.finally(() => setPermissionLoading(false));
|
||||
};
|
||||
|
||||
const togglePermissionApp = (appId: string) => {
|
||||
setPermissionTarget((prev) => ({
|
||||
...prev,
|
||||
appIds: prev.appIds.includes(appId) ? prev.appIds.filter((id) => id !== appId) : [...prev.appIds, appId]
|
||||
}));
|
||||
};
|
||||
|
||||
const saveAppPermissions = () => {
|
||||
if (!permissionTarget.admin || isOwnerAdmin(permissionTarget.admin)) {
|
||||
setPermissionTarget({ admin: null, appIds: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminAppPermissions(permissionTarget.admin, permissionTarget.appIds);
|
||||
setPermissionTarget({ admin: null, appIds: [] });
|
||||
toast({ type: 'success', title: '앱 권한 저장', message: '관리자 앱 권한이 저장되었습니다.' });
|
||||
};
|
||||
|
||||
const submitAdmin = () => {
|
||||
if (!adminForm.email.trim() || !adminForm.name.trim()) return;
|
||||
setSaveState('saving');
|
||||
createAdmin(adminForm)
|
||||
.then(() => {
|
||||
setSaveState('idle');
|
||||
setCreateOpen(false);
|
||||
setAdminForm(emptyAdminForm);
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
setSaveState('error');
|
||||
toast({ type: 'error', title: '관리자 등록 실패', message: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
const submitPassword = () => {
|
||||
if (!passwordTarget.admin) return;
|
||||
setPasswordState('saving');
|
||||
resetAdminPassword(passwordTarget.admin.id, passwordTarget.temporaryPassword)
|
||||
.then(() => {
|
||||
setPasswordState('idle');
|
||||
setPasswordTarget({ admin: null, temporaryPassword: '' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
setPasswordState('error');
|
||||
toast({ type: 'error', title: '비밀번호 초기화 실패', message: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
const submitSshKey = () => {
|
||||
if (!sshTarget.admin || !sshTarget.publicKey.trim()) return;
|
||||
setSshState('saving');
|
||||
setSshError('');
|
||||
createAdminSshKey(sshTarget.admin.id, {
|
||||
label: sshTarget.label.trim() || undefined,
|
||||
giteaUsername: sshTarget.giteaUsername.trim() || undefined,
|
||||
publicKey: sshTarget.publicKey.trim()
|
||||
})
|
||||
.then(() => {
|
||||
setSshState('idle');
|
||||
setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' });
|
||||
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
setSshError(error.message);
|
||||
setSshState('error');
|
||||
toast({ type: 'error', title: 'SSH 키 등록 실패', message: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
const removeSshKey = (admin: AdminAccount, keyId: string) => {
|
||||
deleteAdminSshKey(admin.id, keyId)
|
||||
.then(() => {
|
||||
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error) => toast({ type: 'error', title: 'SSH 키 삭제 실패', message: error.message }));
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<AdminRow>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||
{
|
||||
key: 'name',
|
||||
header: '관리자',
|
||||
className: 'w-[260px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="font-semibold">{row.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.email}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: '권한',
|
||||
cell: (row) => (
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs font-semibold text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={row.role}
|
||||
onChange={(event) => {
|
||||
updateAdminRole(row.id, event.target.value).then(refresh).catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
{roleOptions.some((role) => role.code === row.role) ? null : <option value={row.role}>{row.role}</option>}
|
||||
{roleOptions.map((role) => (
|
||||
<option key={role.code} value={role.code}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'session',
|
||||
header: '최근 세션',
|
||||
cell: (row) => {
|
||||
const session = row.sessions?.[0];
|
||||
const state = session ? (session.revokedAt ? '종료' : '활성') : '세션 없음';
|
||||
return <StatusBadge status={state} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'sshKeys',
|
||||
header: 'SSH 키',
|
||||
cell: (row) => {
|
||||
const keyCount = row.sshKeys?.length ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => openSshModal(row)}
|
||||
>
|
||||
<StatusBadge status={`${keyCount}개`} tone={keyCount > 0 ? 'green' : 'neutral'} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ key: 'lastLoginAt', header: '마지막 로그인', cell: (row) => <span className="text-muted-foreground">{formatDateTime(row.lastLoginAt)}</span> },
|
||||
{ key: 'state', header: '상태', cell: (row) => <StatusBadge status={row.state} tone={statusTone(row.state)} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuAdmin((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[menuAdmin?.id, roleOptions]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
||||
() => [
|
||||
{ key: 'role', label: '권한', options: [...new Set(rows.map((row) => row.role))].map((role) => ({ label: role, value: role })), getValue: (row) => row.role },
|
||||
{ key: 'state', label: '상태', options: [...new Set(rows.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||
],
|
||||
[rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="관리자 관리"
|
||||
description="스튜디오 어드민 콘솔에 접근할 운영자 계정과 권한을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
관리자 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="관리자명, 이메일, 권한 검색"
|
||||
getSearchText={(row) => `${row.name} ${row.email} ${row.role} ${row.state}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 관리자가 없습니다."
|
||||
/>
|
||||
{menuAdmin ? (() => {
|
||||
const row = rows.find((item) => item.id === menuAdmin.id);
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-36 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuAdmin.x, top: menuAdmin.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openSshModal(row)}
|
||||
>
|
||||
SSH 키 등록
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openPermissionModal(row)}
|
||||
>
|
||||
앱 권한 설정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMenuAdmin(null);
|
||||
updateAdminStatus(row.id, !row.isActive).then(refresh).catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
{row.isActive ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMenuAdmin(null);
|
||||
setPasswordTarget({ admin: row, temporaryPassword: '' });
|
||||
}}
|
||||
>
|
||||
비밀번호 초기화
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
<LoadingModal open={loadingVisible} />
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="관리자 등록"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
||||
<Button onClick={submitAdmin} disabled={saveState === 'saving'}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이메일</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.email} onChange={(event) => setAdminForm((prev) => ({ ...prev, email: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">이름</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.name} onChange={(event) => setAdminForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">권한</span>
|
||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.role} onChange={(event) => setAdminForm((prev) => ({ ...prev, role: event.target.value }))}>
|
||||
{roleOptions.map((role) => (
|
||||
<option key={role.code} value={role.code}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={adminForm.temporaryPassword} onChange={(event) => setAdminForm((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(passwordTarget.admin)}
|
||||
title="비밀번호 초기화"
|
||||
onClose={() => setPasswordTarget({ admin: null, temporaryPassword: '' })}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPasswordTarget({ admin: null, temporaryPassword: '' })}>취소</Button>
|
||||
<Button onClick={submitPassword} disabled={passwordState === 'saving'}>{passwordState === 'saving' ? '저장 중' : '초기화'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">새 임시 비밀번호</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={passwordTarget.temporaryPassword} onChange={(event) => setPasswordTarget((prev) => ({ ...prev, temporaryPassword: event.target.value }))} />
|
||||
</label>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(sshTarget.admin)}
|
||||
title="SSH 키 등록"
|
||||
onClose={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}
|
||||
className="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>취소</Button>
|
||||
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">키 이름</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.label} onChange={(event) => setSshTarget((prev) => ({ ...prev, label: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">저장소 사용자명</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.giteaUsername} onChange={(event) => setSshTarget((prev) => ({ ...prev, giteaUsername: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">공개키</span>
|
||||
<textarea className="mt-2 min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={sshTarget.publicKey} onChange={(event) => setSshTarget((prev) => ({ ...prev, publicKey: event.target.value }))} />
|
||||
</label>
|
||||
{sshError ? <p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">{sshError}</p> : null}
|
||||
{sshTarget.admin?.sshKeys?.length ? (
|
||||
<div className="rounded-md border border-border">
|
||||
{sshTarget.admin.sshKeys.map((key) => (
|
||||
<div key={key.id} className="flex items-center justify-between gap-3 border-b border-border px-3 py-2 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{key.label ?? 'SSH Key'}</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{key.fingerprint}</p>
|
||||
</div>
|
||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && removeSshKey(sshTarget.admin, key.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(permissionTarget.admin)}
|
||||
title="앱 권한"
|
||||
onClose={() => setPermissionTarget({ admin: null, appIds: [] })}
|
||||
className="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPermissionTarget({ admin: null, appIds: [] })}>취소</Button>
|
||||
<Button onClick={saveAppPermissions} disabled={permissionLoading || isOwnerAdmin(permissionTarget.admin)}>
|
||||
저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||
<p className="text-sm font-medium text-foreground">{permissionTarget.admin?.name ?? '-'}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{permissionTarget.admin?.email ?? '-'}</p>
|
||||
</div>
|
||||
{isOwnerAdmin(permissionTarget.admin) ? (
|
||||
<div className="rounded-md border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary-foreground">
|
||||
오너 권한은 모든 앱에 접근할 수 있습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-80 overflow-y-auto rounded-md border border-border">
|
||||
{permissionLoading ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">앱 목록을 불러오는 중입니다.</div>
|
||||
) : permissionApps.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">등록된 앱이 없습니다.</div>
|
||||
) : (
|
||||
permissionApps.map((app) => {
|
||||
const checked = isOwnerAdmin(permissionTarget.admin) || permissionTarget.appIds.includes(app.id);
|
||||
return (
|
||||
<label key={app.id} className="flex cursor-pointer items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-foreground">{app.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{app.key} · {app.namespace || '-'}</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-primary"
|
||||
checked={checked}
|
||||
disabled={isOwnerAdmin(permissionTarget.admin)}
|
||||
onChange={() => togglePermissionApp(app.id)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { Button, Card, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateOnly } from '../../lib/date-format';
|
||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
import {
|
||||
assignUserLicense,
|
||||
loadLicensePlans,
|
||||
loadLicenseUsers,
|
||||
revokeUserLicense,
|
||||
updateUserLicense,
|
||||
type AuthLicenseUser,
|
||||
type LicensePlan
|
||||
} from './operations-api';
|
||||
|
||||
type LicenseAssignmentRow = {
|
||||
id: string;
|
||||
userId: string;
|
||||
user: string;
|
||||
name: string;
|
||||
role: string;
|
||||
code: string;
|
||||
licenseName: string;
|
||||
startsAt: string;
|
||||
expiresAt?: string | null;
|
||||
assignedBy?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(dateValue: string, days?: number | null) {
|
||||
if (!dateValue || !days) return '';
|
||||
const date = new Date(`${dateValue}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatOptionalDate(value?: string | null) {
|
||||
if (value == null) return '-';
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed || trimmed === 'null' || trimmed === 'undefined') return '-';
|
||||
return formatDateOnly(trimmed);
|
||||
}
|
||||
|
||||
function toDateInputValue(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function assignmentRows(users: AuthLicenseUser[]) {
|
||||
return users.flatMap((user) =>
|
||||
user.licenses.map((license) => ({
|
||||
id: license.id,
|
||||
userId: user.id,
|
||||
user: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
code: license.license.code,
|
||||
licenseName: license.license.name,
|
||||
startsAt: license.startsAt,
|
||||
expiresAt: license.expiresAt,
|
||||
assignedBy: license.assignedById,
|
||||
status: license.status
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function LicensesPage() {
|
||||
const { toast } = useToast();
|
||||
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
||||
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LicenseAssignmentRow | null>(null);
|
||||
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [assignForm, setAssignForm] = useState({
|
||||
userId: '',
|
||||
licenseCode: '',
|
||||
startsAt: today(),
|
||||
expiresAt: ''
|
||||
});
|
||||
const [userKeyword, setUserKeyword] = useState('');
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const rows = useMemo(() => assignmentRows(users), [users]);
|
||||
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
||||
const plan = plans.find((item) => item.code === licenseCode);
|
||||
return addDays(startsAt, plan?.defaultDurationDays);
|
||||
};
|
||||
const filteredUsers = useMemo(() => {
|
||||
const keyword = userKeyword.trim().toLowerCase();
|
||||
if (!keyword) return [];
|
||||
return users.filter((user) => `${user.email} ${user.name}`.toLowerCase().includes(keyword));
|
||||
}, [userKeyword, users]);
|
||||
const selectedUser = useMemo(() => users.find((user) => user.id === assignForm.userId), [assignForm.userId, users]);
|
||||
const selectedUserCurrentLicense = selectedUser?.currentLicense ?? null;
|
||||
const cannotAssignBecauseActive = Boolean(!editingRow && selectedUserCurrentLicense);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
||||
setPlans(nextPlans);
|
||||
setUsers(nextUsers);
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
||||
expiresAt: prev.expiresAt || addDays(prev.startsAt, nextPlans.find((plan) => plan.code === (prev.licenseCode || nextPlans[0]?.code || ''))?.defaultDurationDays)
|
||||
}));
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 정보를 불러오지 못했습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRow) return;
|
||||
|
||||
const close = () => setMenuRow(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuRow(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuRow]);
|
||||
|
||||
const openAssignModal = () => {
|
||||
const startsAt = today();
|
||||
const licenseCode = assignForm.licenseCode || plans[0]?.code || '';
|
||||
setEditingRow(null);
|
||||
setAssignForm((prev) => ({
|
||||
userId: '',
|
||||
licenseCode,
|
||||
startsAt,
|
||||
expiresAt: calculateExpiresAt(licenseCode, startsAt)
|
||||
}));
|
||||
setUserKeyword('');
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (row: LicenseAssignmentRow) => {
|
||||
const startsAt = toDateInputValue(row.startsAt) || today();
|
||||
setMenuRow(null);
|
||||
setEditingRow(row);
|
||||
setAssignForm({
|
||||
userId: row.userId,
|
||||
licenseCode: row.code,
|
||||
startsAt,
|
||||
expiresAt: toDateInputValue(row.expiresAt)
|
||||
});
|
||||
setUserKeyword('');
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const closeAssignModal = () => {
|
||||
setAssignOpen(false);
|
||||
setEditingRow(null);
|
||||
setUserKeyword('');
|
||||
};
|
||||
|
||||
const submitAssign = async () => {
|
||||
if (!assignForm.userId || !assignForm.licenseCode) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
licenseCode: assignForm.licenseCode,
|
||||
startsAt: assignForm.startsAt || undefined,
|
||||
expiresAt: assignForm.expiresAt || undefined
|
||||
};
|
||||
if (editingRow) {
|
||||
await updateUserLicense(editingRow.id, payload);
|
||||
} else {
|
||||
await assignUserLicense(assignForm.userId, payload);
|
||||
}
|
||||
toast({ type: 'success', message: editingRow ? '라이센스를 수정했습니다.' : '라이센스를 부여했습니다.' });
|
||||
closeAssignModal();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : editingRow ? '라이센스 수정에 실패했습니다.' : '라이센스 부여에 실패했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await revokeUserLicense(deleteTarget.id);
|
||||
toast({ type: 'success', message: '라이센스를 삭제했습니다.' });
|
||||
setDeleteTarget(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 삭제에 실패했습니다.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'user',
|
||||
header: '사용자',
|
||||
className: 'w-[260px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="text-foreground">{row.user}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{row.name}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'licenseName', header: '라이선스명', cell: (row) => <span className="text-muted-foreground">{row.licenseName}</span> },
|
||||
{ key: 'startsAt', header: '시작일', cell: (row) => <span className="text-muted-foreground">{formatDateOnly(row.startsAt)}</span> },
|
||||
{ key: 'expiresAt', header: '만료일', cell: (row) => <span className="text-muted-foreground">{formatOptionalDate(row.expiresAt)}</span> },
|
||||
{ key: 'assignedBy', header: '부여자', cell: (row) => <span className="text-muted-foreground">{row.assignedBy || '-'}</span> },
|
||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuRow((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
|
||||
() => [
|
||||
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName },
|
||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status }
|
||||
],
|
||||
[plans, rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="라이센스"
|
||||
description="공통 인증 DB 기준으로 라이센스 플랜과 사용자 부여 이력을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
||||
<Plus className="size-4" />
|
||||
라이센스 부여
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<Card key={plan.code} className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">{plan.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
||||
</div>
|
||||
<StatusBadge status={plan.isActive ? '활성' : '비활성'} />
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">부여</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.assignedCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">활성</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.activeUserCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">만료임박</p>
|
||||
<p className="mt-1 text-xl font-semibold">{plan.expiringSoonCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-muted-foreground">기본 기간 {plan.defaultDurationDays ?? '-'}일</p>
|
||||
</Card>
|
||||
))}
|
||||
{!loading && plans.length === 0 ? (
|
||||
<Card className="flex min-h-40 items-center justify-center p-5 text-sm text-muted-foreground xl:col-span-3">
|
||||
등록된 라이센스 플랜이 없습니다.
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTableCard
|
||||
title="부여 이력"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="사용자, 라이선스, 부여자 검색"
|
||||
getSearchText={(row) => `${row.user} ${row.name} ${row.licenseName} ${row.assignedBy ?? ''} ${row.status}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={980}
|
||||
loading={loading}
|
||||
emptyMessage="부여된 라이센스가 없습니다."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{menuRow ? (() => {
|
||||
const row = rows.find((item) => item.id === menuRow.id);
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuRow.x, top: menuRow.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openEditModal(row)}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10"
|
||||
onClick={() => {
|
||||
setMenuRow(null);
|
||||
setDeleteTarget(row);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
<Modal
|
||||
open={assignOpen}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
{editingRow ? '라이센스 수정' : '라이센스 부여'}
|
||||
</span>
|
||||
}
|
||||
onClose={closeAssignModal}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeAssignModal} disabled={saving}>취소</Button>
|
||||
<Button disabled={saving || !assignForm.userId || !assignForm.licenseCode || cannotAssignBecauseActive} onClick={submitAssign}>
|
||||
{saving ? '저장 중' : editingRow ? '저장' : '부여'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editingRow ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
|
||||
<p className="text-foreground">{editingRow.user}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||
<input
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={userKeyword}
|
||||
onChange={(event) => setUserKeyword(event.target.value)}
|
||||
placeholder="이메일 또는 이름 검색"
|
||||
/>
|
||||
{selectedUser ? (
|
||||
<div className="mt-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm">
|
||||
<p className="text-foreground">{selectedUser.email}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{selectedUser.name || '-'}</p>
|
||||
{selectedUserCurrentLicense ? (
|
||||
<p className="mt-2 rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-200">
|
||||
이미 활성 라이센스가 있습니다. 기존 이력에서 수정하세요.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{userKeyword.trim() ? (
|
||||
<div className="mt-2 max-h-44 overflow-y-auto rounded-md border border-border bg-background/80">
|
||||
{filteredUsers.map((user) => {
|
||||
const selected = assignForm.userId === user.id;
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition hover:bg-white/5 ${selected ? 'bg-primary/15 text-foreground' : 'text-muted-foreground'}`}
|
||||
onClick={() => {
|
||||
setAssignForm((prev) => ({ ...prev, userId: user.id }));
|
||||
setUserKeyword('');
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="block text-foreground">{user.email}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{user.name || '-'}</span>
|
||||
</span>
|
||||
{selected ? <span className="text-xs text-primary">선택됨</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">검색된 사용자가 없습니다.</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
|
||||
<select
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={assignForm.licenseCode}
|
||||
onChange={(event) => {
|
||||
const licenseCode = event.target.value;
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
licenseCode,
|
||||
expiresAt: calculateExpiresAt(licenseCode, prev.startsAt)
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{plans.map((plan) => <option key={plan.code} value={plan.code}>{plan.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">시작일</span>
|
||||
<input
|
||||
type="date"
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={assignForm.startsAt}
|
||||
onChange={(event) => {
|
||||
const startsAt = event.target.value;
|
||||
setAssignForm((prev) => ({
|
||||
...prev,
|
||||
startsAt,
|
||||
expiresAt: calculateExpiresAt(prev.licenseCode, startsAt)
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">만료일</span>
|
||||
<input
|
||||
type="date"
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={assignForm.expiresAt}
|
||||
onChange={(event) => setAssignForm((prev) => ({ ...prev, expiresAt: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-rose-300" />
|
||||
라이센스 삭제
|
||||
</span>
|
||||
}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={revoke} disabled={saving}>
|
||||
{saving ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="pb-6 text-sm text-foreground">라이센스를 삭제할까요?</p>
|
||||
</Modal>
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { Button, Modal } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { listApps } from '../applications/application-api';
|
||||
import {
|
||||
createLauncherNotice,
|
||||
deleteLauncherNotice,
|
||||
loadLauncherNotices,
|
||||
updateLauncherNotice,
|
||||
type LauncherNotice
|
||||
} from './operations-api';
|
||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
import type { AppWithComponents } from '../../types/application';
|
||||
|
||||
type NoticeForm = {
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
priority: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: NoticeForm = {
|
||||
appKey: 'launcher',
|
||||
title: '',
|
||||
body: '',
|
||||
linkUrl: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
priority: '0',
|
||||
isActive: true
|
||||
};
|
||||
|
||||
function formatNoticeDateTime(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function toDateTimeInputValue(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mi = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function toApiDateTime(value: string) {
|
||||
return value ? new Date(value).toISOString() : undefined;
|
||||
}
|
||||
|
||||
function exposureText(row: LauncherNotice) {
|
||||
if (!row.startsAt && !row.endsAt) return '상시';
|
||||
if (row.startsAt && row.endsAt) return `${formatNoticeDateTime(row.startsAt)} ~ ${formatNoticeDateTime(row.endsAt)}`;
|
||||
if (row.startsAt) return `${formatNoticeDateTime(row.startsAt)}부터`;
|
||||
return `${formatNoticeDateTime(row.endsAt)}까지`;
|
||||
}
|
||||
|
||||
function toForm(notice: LauncherNotice): NoticeForm {
|
||||
return {
|
||||
appKey: notice.appKey,
|
||||
title: notice.title,
|
||||
body: notice.body,
|
||||
linkUrl: notice.linkUrl ?? '',
|
||||
startsAt: toDateTimeInputValue(notice.startsAt),
|
||||
endsAt: toDateTimeInputValue(notice.endsAt),
|
||||
priority: String(notice.priority ?? 0),
|
||||
isActive: notice.isActive
|
||||
};
|
||||
}
|
||||
|
||||
function isLauncherNoticeKey(appKey: string) {
|
||||
return appKey.toLowerCase() === 'launcher' || appKey.toLowerCase() === 'login';
|
||||
}
|
||||
|
||||
export function NoticesPage() {
|
||||
const { toast } = useToast();
|
||||
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LauncherNotice | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LauncherNotice | null>(null);
|
||||
const [menuNotice, setMenuNotice] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [form, setForm] = useState<NoticeForm>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadLauncherNotices(),
|
||||
listApps().catch(() => [])
|
||||
])
|
||||
.then(([items, appItems]) => {
|
||||
setNotices(items);
|
||||
setApps(appItems);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setNotices([]);
|
||||
setFailed(true);
|
||||
toast({ type: 'error', title: '공지사항 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuNotice) return;
|
||||
|
||||
const close = () => setMenuNotice(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuNotice(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuNotice]);
|
||||
|
||||
const appNameByKey = useMemo(() => {
|
||||
const map = new Map<string, string>([
|
||||
['launcher', 'Launcher'],
|
||||
['login', 'Launcher']
|
||||
]);
|
||||
apps.forEach((app) => map.set(app.key, app.name));
|
||||
return map;
|
||||
}, [apps]);
|
||||
|
||||
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
||||
|
||||
const rows = useMemo(
|
||||
() => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })),
|
||||
[appNameByKey, notices]
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (notice: LauncherNotice) => {
|
||||
setMenuNotice(null);
|
||||
setEditing(notice);
|
||||
setForm(toForm(notice));
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const payload = () => ({
|
||||
appKey: form.appKey.trim(),
|
||||
title: form.title.trim(),
|
||||
body: form.body.trim(),
|
||||
linkUrl: form.linkUrl.trim(),
|
||||
startsAt: toApiDateTime(form.startsAt),
|
||||
endsAt: toApiDateTime(form.endsAt),
|
||||
priority: Number(form.priority) || 0,
|
||||
isActive: form.isActive
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (!form.appKey.trim() || !form.title.trim() || !form.body.trim()) return;
|
||||
setSaving(true);
|
||||
const request = editing
|
||||
? updateLauncherNotice(editing.id, payload())
|
||||
: createLauncherNotice(payload());
|
||||
|
||||
request
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: editing ? '공지사항을 수정했습니다.' : '공지사항을 등록했습니다.' });
|
||||
closeModal();
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: editing ? '공지사항 수정 실패' : '공지사항 등록 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const removeNotice = (notice: LauncherNotice) => {
|
||||
setMenuNotice(null);
|
||||
setDeleteTarget(notice);
|
||||
};
|
||||
|
||||
const confirmRemoveNotice = () => {
|
||||
if (!deleteTarget) return;
|
||||
setSaving(true);
|
||||
deleteLauncherNotice(deleteTarget.id)
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '공지사항을 삭제했습니다.' });
|
||||
setDeleteTarget(null);
|
||||
closeModal();
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '공지사항 삭제 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||
{
|
||||
key: 'title',
|
||||
header: '공지',
|
||||
className: 'w-[300px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="truncate">{row.title}</p>
|
||||
<p className="mt-1 max-w-[320px] truncate text-xs text-muted-foreground">{row.body}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> },
|
||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status ?? (row.isActive ? '게시중' : '비활성')} /> },
|
||||
{ key: 'exposure', header: '노출 일시', cell: (row) => <span className="text-muted-foreground">{exposureText(row)}</span> },
|
||||
{
|
||||
key: 'linkUrl',
|
||||
header: '링크주소',
|
||||
cell: (row) => row.linkUrl ? <span className="block max-w-56 truncate text-muted-foreground">{row.linkUrl}</span> : <span className="text-muted-foreground">-</span>
|
||||
},
|
||||
{ key: 'createdAt', header: '생성일시', cell: (row) => <span className="text-muted-foreground">{formatNoticeDateTime(row.createdAt)}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuNotice((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[menuNotice?.id]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{ key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey },
|
||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status ?? (row.isActive ? '게시중' : '비활성')))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status ?? (row.isActive ? '게시중' : '비활성') }
|
||||
],
|
||||
[rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="공지사항"
|
||||
description="런처와 앱 화면에 노출할 공지사항을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
공지사항 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="공지 제목, 대상 앱, 링크 검색"
|
||||
getSearchText={(row) => `${row.title} ${row.body} ${row.appKey} ${row.appName} ${row.linkUrl ?? ''} ${row.status ?? ''}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 공지사항이 없습니다."
|
||||
/>
|
||||
|
||||
{menuNotice ? (() => {
|
||||
const row = rows.find((item) => item.id === menuNotice.id);
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuNotice.x, top: menuNotice.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10"
|
||||
onClick={() => removeNotice(row)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
공지사항
|
||||
</span>
|
||||
}
|
||||
onClose={closeModal}
|
||||
className="!max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
|
||||
<Button onClick={submit} disabled={saving || !form.appKey.trim() || !form.title.trim() || !form.body.trim()}>
|
||||
{saving ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">대상 앱</span>
|
||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
|
||||
<option value="launcher">Launcher</option>
|
||||
{apps.map((app) => (
|
||||
<option key={app.id} value={app.key}>{app.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">게시 여부</span>
|
||||
<span className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((prev) => ({ ...prev, isActive: event.target.checked }))} />
|
||||
게시
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">제목</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">본문</span>
|
||||
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">링크주소</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.linkUrl} onChange={(event) => setForm((prev) => ({ ...prev, linkUrl: event.target.value }))} />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
||||
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 종료 일시</span>
|
||||
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-rose-300" />
|
||||
공지사항 삭제
|
||||
</span>
|
||||
}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmRemoveNotice} disabled={saving}>
|
||||
{saving ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="pb-6 text-sm text-foreground">공지사항을 삭제할까요?</p>
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { Button } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
|
||||
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
|
||||
export function UserSessionsPage() {
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
loadUserSessions()
|
||||
.then((nextSessions) => {
|
||||
setSessions(nextSessions);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setSessions([]);
|
||||
setFailed(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuSession) return;
|
||||
|
||||
const close = () => setMenuSession(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuSession(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuSession]);
|
||||
|
||||
const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]);
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||
{ key: 'user', header: '사용자', className: 'w-[220px] px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.user}</span> },
|
||||
{ key: 'role', header: '권한', cell: (row) => <span className="text-muted-foreground">{row.role}</span> },
|
||||
{ key: 'session', header: '세션', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.session}</span> },
|
||||
{ key: 'expiresAt', header: '만료', cell: (row) => <span className="text-muted-foreground">{formatDateTime(row.expiresAt)}</span> },
|
||||
{ key: 'lastSeen', header: '최근 접속', cell: (row) => <span className="text-muted-foreground">{formatDateTime(row.lastSeen)}</span> },
|
||||
{ key: 'state', header: '상태', cell: (row) => <StatusBadge status={row.state} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuSession((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[menuSession?.id]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
||||
() => [
|
||||
{ key: 'state', label: '상태', options: [...new Set(sessions.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||
],
|
||||
[sessions]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="운영" title="사용자/세션" description="접속 중인 사용자와 세션 상태를 확인하고 필요한 제어 작업을 실행합니다." />
|
||||
{failed ? (
|
||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
사용자/세션 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="사용자, 이메일, 권한, 세션 검색"
|
||||
getSearchText={(row) => `${row.user} ${row.email ?? ''} ${row.role} ${row.session} ${row.state}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
loading={loading}
|
||||
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
||||
/>
|
||||
{menuSession ? (() => {
|
||||
const row = rows.find((item) => item.id === menuSession.id);
|
||||
if (!row) return null;
|
||||
const locked = row.state === '잠김';
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuSession.x, top: menuSession.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMenuSession(null);
|
||||
const request = locked ? unlockUser(row.userId) : lockUser(row.userId);
|
||||
request.then(refresh).catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
{locked ? '잠금 해제' : '잠금'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:bg-transparent"
|
||||
disabled={!row.sessionId || row.state === '종료' || row.state === '만료'}
|
||||
onClick={() => {
|
||||
if (!row.sessionId) return;
|
||||
setMenuSession(null);
|
||||
terminateUserSession(row.sessionId).then(refresh).catch(() => setFailed(true));
|
||||
}}
|
||||
>
|
||||
세션 종료
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '../../components/ui';
|
||||
import { formatDateTime as formatDateTimeValue } from '../../lib/date-format';
|
||||
|
||||
export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
||||
|
||||
export function statusTone(status?: string | null): BadgeTone {
|
||||
if (!status) return 'neutral';
|
||||
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
||||
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||
if (['런처'].includes(status)) return 'violet';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, tone }: { status: string; tone?: BadgeTone }) {
|
||||
return <Badge tone={tone ?? statusTone(status)}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
return formatDateTimeValue(value);
|
||||
}
|
||||
|
||||
export function LoadingModal({ open }: { open: boolean }) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 top-16 z-[15] flex items-center justify-center bg-background/20 px-4 backdrop-blur-[1px] lg:left-[17rem]">
|
||||
<div className="flex min-h-28 min-w-72 items-center justify-center gap-3 rounded-lg border border-border bg-card px-8 py-6 text-sm font-semibold shadow-2xl lg:-translate-x-[8.5rem]">
|
||||
<LoaderCircle className="size-5 animate-spin text-primary" />
|
||||
데이터를 불러오는 중입니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
||||
const [visible, setVisible] = useState(loading);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setVisible(false), minimumMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loading, minimumMs]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
|
||||
export type AdminSession = {
|
||||
id?: string;
|
||||
revokedAt?: string | null;
|
||||
};
|
||||
|
||||
export type AdminSshKey = {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
fingerprint?: string | null;
|
||||
};
|
||||
|
||||
export type AdminAccount = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'OWNER' | 'ADMIN' | 'OPERATOR' | 'VIEWER' | string;
|
||||
isActive: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
sessions?: AdminSession[];
|
||||
sshKeys?: AdminSshKey[];
|
||||
};
|
||||
|
||||
export type AdminRoleOption = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type UserSession = {
|
||||
id: string;
|
||||
no?: number;
|
||||
userId: string;
|
||||
sessionId?: string | null;
|
||||
user: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
session: string;
|
||||
expiresAt?: string | null;
|
||||
lastSeen?: string | null;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type LauncherNotice = {
|
||||
id: string;
|
||||
no?: number;
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl?: string | null;
|
||||
isActive: boolean;
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
priority: number;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export async function loadAdmins() {
|
||||
const response = await apiRequest<AdminAccount[] | { admins?: AdminAccount[] }>('/admins');
|
||||
return Array.isArray(response) ? response : response.admins ?? [];
|
||||
}
|
||||
|
||||
export function loadAdminRoles() {
|
||||
return apiRequest<AdminRoleOption[]>('/admins/roles');
|
||||
}
|
||||
|
||||
export function createAdmin(payload: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
temporaryPassword?: string;
|
||||
}) {
|
||||
return apiRequest('/admins', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdminStatus(adminId: string, isActive: boolean) {
|
||||
return apiRequest(`/admins/${adminId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdminRole(adminId: string, role: string) {
|
||||
return apiRequest(`/admins/${adminId}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role })
|
||||
});
|
||||
}
|
||||
|
||||
export function resetAdminPassword(adminId: string, temporaryPassword: string) {
|
||||
return apiRequest(`/admins/${adminId}/password`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ temporaryPassword })
|
||||
});
|
||||
}
|
||||
|
||||
export function createAdminSshKey(
|
||||
adminId: string,
|
||||
payload: { label?: string; giteaUsername?: string; publicKey: string }
|
||||
) {
|
||||
return apiRequest(`/admins/${adminId}/ssh-keys`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAdminSshKey(adminId: string, keyId: string) {
|
||||
return apiRequest(`/admins/${adminId}/ssh-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadUserSessions() {
|
||||
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
|
||||
return response.sessions ?? [];
|
||||
}
|
||||
|
||||
export function lockUser(userId: string) {
|
||||
return apiRequest(`/users/${userId}/lock`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
}
|
||||
|
||||
export function unlockUser(userId: string) {
|
||||
return apiRequest(`/users/${userId}/unlock`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
}
|
||||
|
||||
export function terminateUserSession(sessionId: string) {
|
||||
return apiRequest(`/users/sessions/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadLauncherNotices() {
|
||||
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
||||
return response.notices ?? [];
|
||||
}
|
||||
|
||||
export function createLauncherNotice(payload: {
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl?: string;
|
||||
isActive?: boolean;
|
||||
startsAt?: string;
|
||||
endsAt?: string;
|
||||
priority?: number;
|
||||
}) {
|
||||
return apiRequest<{ notice: LauncherNotice }>('/notices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLauncherNotice(
|
||||
noticeId: string,
|
||||
payload: Partial<{
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl: string;
|
||||
isActive: boolean;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
priority: number;
|
||||
}>
|
||||
) {
|
||||
return apiRequest<{ notice: LauncherNotice }>(`/notices/${noticeId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteLauncherNotice(noticeId: string) {
|
||||
return apiRequest<{ ok: boolean }>(`/notices/${noticeId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export type LicensePlan = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
defaultDurationDays?: number | null;
|
||||
isActive: boolean;
|
||||
assignedCount: number;
|
||||
activeUserCount: number;
|
||||
expiringSoonCount: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type AuthUserLicense = {
|
||||
id: string;
|
||||
userId: string;
|
||||
licenseId: string;
|
||||
startsAt: string;
|
||||
expiresAt?: string | null;
|
||||
assignedById?: string | null;
|
||||
license: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type AuthLicenseUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
currentLicense?: AuthUserLicense | null;
|
||||
licenses: AuthUserLicense[];
|
||||
};
|
||||
|
||||
export async function loadLicensePlans() {
|
||||
const response = await apiRequest<{ plans?: LicensePlan[] }>('/licenses/plans');
|
||||
return response.plans ?? [];
|
||||
}
|
||||
|
||||
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
||||
if (params?.licenseCode) searchParams.set('licenseCode', params.licenseCode);
|
||||
const suffix = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||
const response = await apiRequest<{ users?: AuthLicenseUser[] }>(`/licenses/users${suffix}`);
|
||||
return response.users ?? [];
|
||||
}
|
||||
|
||||
export function assignUserLicense(userId: string, payload: {
|
||||
licenseCode: string;
|
||||
startsAt?: string;
|
||||
expiresAt?: string;
|
||||
}) {
|
||||
return apiRequest<{ userLicense: AuthUserLicense }>(`/licenses/users/${userId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUserLicense(userLicenseId: string, payload: {
|
||||
licenseCode: string;
|
||||
startsAt?: string;
|
||||
expiresAt?: string;
|
||||
}) {
|
||||
return apiRequest<{ userLicense: AuthUserLicense }>(`/licenses/user-licenses/${userLicenseId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeUserLicense(userLicenseId: string) {
|
||||
return apiRequest<{ ok: boolean }>(`/licenses/user-licenses/${userLicenseId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user