init studio admin frontend

This commit is contained in:
2026-07-01 17:34:15 +09:00
commit c50bd2b941
64 changed files with 22452 additions and 0 deletions
@@ -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>
</>
);
}