550 lines
24 KiB
TypeScript
550 lines
24 KiB
TypeScript
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 { isOwnerAdmin, setAdminAppPermissions } from '../../lib/admin-app-permissions';
|
|
import { listApps } from '../applications/application-api';
|
|
import type { AppWithComponents } from '../../types/application';
|
|
import {
|
|
createAdmin,
|
|
createAdminSshKey,
|
|
deleteAdminSshKey,
|
|
loadAdminAppPermissions,
|
|
loadAdminRoles,
|
|
loadAdmins,
|
|
resetAdminPassword,
|
|
updateAdminAppPermissions,
|
|
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 [permissionSaving, setPermissionSaving] = 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: [] });
|
|
setPermissionLoading(true);
|
|
Promise.all([
|
|
listApps(),
|
|
isOwnerAdmin(admin) ? Promise.resolve<string[]>([]) : loadAdminAppPermissions(admin.id)
|
|
])
|
|
.then(([apps, appIds]) => {
|
|
setPermissionApps(apps);
|
|
setPermissionTarget({ admin, appIds: isOwnerAdmin(admin) ? apps.map((app) => app.id) : appIds });
|
|
})
|
|
.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;
|
|
}
|
|
|
|
setPermissionSaving(true);
|
|
updateAdminAppPermissions(permissionTarget.admin.id, permissionTarget.appIds)
|
|
.then((result) => {
|
|
setAdminAppPermissions(permissionTarget.admin as AdminAccount, result.appIds ?? permissionTarget.appIds);
|
|
setPermissionTarget({ admin: null, appIds: [] });
|
|
toast({ type: 'success', title: '앱 권한 저장', message: '관리자 앱 권한이 저장되었습니다.' });
|
|
refresh();
|
|
})
|
|
.catch((error) => {
|
|
toast({ type: 'error', title: '앱 권한 저장 실패', message: error instanceof Error ? error.message : '관리자 앱 권한 저장에 실패했습니다.' });
|
|
})
|
|
.finally(() => setPermissionSaving(false));
|
|
};
|
|
|
|
const submitAdmin = () => {
|
|
if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) 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-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center 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: '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={1040}
|
|
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' || !adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8}>{saveState === 'saving' ? '저장 중' : '등록'}</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="grid gap-4">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">이메일 <b className="text-foreground">*</b></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">이름 <b className="text-foreground">*</b></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">권한 <b className="text-foreground">*</b></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">임시 비밀번호 <b className="text-foreground">*</b></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: [] })} disabled={permissionSaving}>취소</Button>
|
|
<Button onClick={saveAppPermissions} disabled={permissionLoading || permissionSaving || isOwnerAdmin(permissionTarget.admin)}>
|
|
{permissionSaving ? '저장 중' : '저장'}
|
|
</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>
|
|
</>
|
|
);
|
|
}
|