track requesters for builds releases deployments and migrations
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-10 10:42:56 +09:00
parent ad52c48bca
commit 9625752a81
20 changed files with 1166 additions and 53 deletions
+77 -24
View File
@@ -55,9 +55,9 @@ export function AdminManagementPage() {
const [sshTarget, setSshTarget] = useState({
admin: null as AdminAccount | null,
label: '',
giteaUsername: '',
publicKey: ''
});
const [sshDeleteTarget, setSshDeleteTarget] = useState<{ admin: AdminAccount; keyId: string } | null>(null);
const [adminForm, setAdminForm] = useState(emptyAdminForm);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'error'>('idle');
const [passwordState, setPasswordState] = useState<'idle' | 'saving' | 'error'>('idle');
@@ -119,7 +119,7 @@ export function AdminManagementPage() {
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: '' });
setSshTarget({ admin, label: `${account}-${random}-key`, publicKey: '' });
setSshState('idle');
setSshError('');
};
@@ -207,12 +207,11 @@ export function AdminManagementPage() {
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: '' });
setSshTarget({ admin: null, label: '', publicKey: '' });
toast({ type: 'success', title: 'SSH 키 등록 완료', message: '관리자 SSH 키가 등록되었습니다.' });
refresh();
})
@@ -223,9 +222,12 @@ export function AdminManagementPage() {
});
};
const removeSshKey = (admin: AdminAccount, keyId: string) => {
deleteAdminSshKey(admin.id, keyId)
const removeSshKey = () => {
if (!sshDeleteTarget) return;
deleteAdminSshKey(sshDeleteTarget.admin.id, sshDeleteTarget.keyId)
.then(() => {
setSshDeleteTarget(null);
toast({ type: 'success', title: 'SSH 키 삭제 완료', message: '관리자 SSH 키가 삭제되었습니다.' });
refresh();
})
@@ -240,9 +242,12 @@ export function AdminManagementPage() {
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 className="flex min-w-0 items-center gap-3">
<AdminAvatar admin={row} />
<div className="min-w-0">
<p className="truncate font-semibold">{row.name}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{row.email}</p>
</div>
</div>
)
},
@@ -278,7 +283,7 @@ export function AdminManagementPage() {
className="inline-flex rounded-full outline-none focus:ring-2 focus:ring-ring"
onClick={() => openSshModal(row)}
>
<StatusBadge status={`${keyCount}개`} tone={keyCount > 0 ? 'green' : 'neutral'} />
<StatusBadge status={keyCount > 0 ? '등록' : '미등록'} tone={keyCount > 0 ? 'green' : 'neutral'} />
</button>
);
}
@@ -355,13 +360,26 @@ export function AdminManagementPage() {
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>
{row.sshKeys?.length ? (
<button
type="button"
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-muted"
onClick={() => {
setMenuAdmin(null);
setSshDeleteTarget({ admin: row, keyId: row.sshKeys![0].id });
}}
>
SSH 키 삭제
</button>
) : (
<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"
@@ -451,11 +469,11 @@ export function AdminManagementPage() {
<Modal
open={Boolean(sshTarget.admin)}
title="SSH 키 등록"
onClose={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}
onClose={() => setSshTarget({ admin: null, label: '', publicKey: '' })}
className="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', giteaUsername: '', publicKey: '' })}>취소</Button>
<Button variant="outline" onClick={() => setSshTarget({ admin: null, label: '', publicKey: '' })}>취소</Button>
<Button onClick={submitSshKey} disabled={sshState === 'saving'}>{sshState === 'saving' ? '저장 중' : '등록'}</Button>
</>
}
@@ -465,10 +483,9 @@ export function AdminManagementPage() {
<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>
<p className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
저장소 사용자명은 관리자 이메일 기준으로 자동 적용됩니다.
</p>
<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 }))} />
@@ -482,7 +499,7 @@ export function AdminManagementPage() {
<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)}>
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => sshTarget.admin && setSshDeleteTarget({ admin: sshTarget.admin, keyId: key.id })}>
<Trash2 className="size-4" />
</Button>
</div>
@@ -492,6 +509,20 @@ export function AdminManagementPage() {
</div>
</Modal>
<Modal
open={Boolean(sshDeleteTarget)}
title="SSH 키 삭제"
onClose={() => setSshDeleteTarget(null)}
footer={
<>
<Button variant="outline" onClick={() => setSshDeleteTarget(null)}>취소</Button>
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={removeSshKey}>삭제</Button>
</>
}
>
<p className="text-sm text-muted-foreground">등록된 관리자 SSH 키를 삭제합니다. Gitea에 등록된 키도 함께 삭제됩니다.</p>
</Modal>
<Modal
open={Boolean(permissionTarget.admin)}
title="앱 권한"
@@ -547,3 +578,25 @@ export function AdminManagementPage() {
</>
);
}
function AdminAvatar({ admin }: { admin: AdminAccount }) {
const [failed, setFailed] = useState(false);
const initial = (admin.name || admin.email || '?').trim().charAt(0).toUpperCase() || '?';
if (admin.avatarUrl && !failed) {
return (
<img
src={admin.avatarUrl}
alt=""
className="size-9 shrink-0 rounded-full border border-border bg-muted object-cover"
onError={() => setFailed(true)}
/>
);
}
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-sm font-semibold text-muted-foreground">
{initial}
</span>
);
}