improve app registration and component service ports
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 14:26:43 +09:00
parent 8af6c60088
commit ac075a1fea
15 changed files with 301 additions and 60 deletions
+176 -3
View File
@@ -1,16 +1,44 @@
import { MoreHorizontal } from 'lucide-react';
import { MoreHorizontal, Plus } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
import { Button, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils';
import { loadUserSessions, lockUser, terminateUserSession, unlockUser, type UserSession } from './operations-api';
import {
createManagedUser,
loadLicensePlans,
loadOrganizations,
loadUserSessions,
lockUser,
terminateUserSession,
unlockUser,
type LicensePlan,
type Organization,
type UserSession
} from './operations-api';
import { formatDateTime, LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
const emptyCreateForm = {
email: '',
name: '',
password: '',
organizationId: '',
department: '',
position: '',
licenseCode: 'EDU' as 'FREE' | 'EDU' | 'PRO'
};
export function UserSessionsPage() {
const { toast } = useToast();
const [sessions, setSessions] = useState<UserSession[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [createSaving, setCreateSaving] = useState(false);
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
const loadingVisible = useMinimumLoading(loading);
@@ -31,6 +59,18 @@ export function UserSessionsPage() {
useEffect(() => {
refresh();
Promise.all([
loadOrganizations().catch(() => []),
loadLicensePlans().catch(() => [])
]).then(([nextOrganizations, nextLicensePlans]) => {
setOrganizations(nextOrganizations);
setLicensePlans(nextLicensePlans);
setCreateForm((current) => ({
...current,
organizationId: current.organizationId || '',
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
}));
});
}, []);
useEffect(() => {
@@ -105,9 +145,52 @@ export function UserSessionsPage() {
[sessions]
);
const openCreateModal = () => {
setCreateForm({
...emptyCreateForm,
organizationId: '',
licenseCode: (licensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
});
setCreateOpen(true);
};
const submitCreateUser = () => {
if (!createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8) return;
setCreateSaving(true);
createManagedUser({
email: createForm.email.trim(),
name: createForm.name.trim(),
password: createForm.password,
organizationId: createForm.organizationId || null,
department: createForm.department.trim() || null,
position: createForm.position.trim() || null,
licenseCode: createForm.licenseCode
})
.then(() => {
toast({ type: 'success', title: '사용자 등록', message: '사용자가 등록되었습니다.' });
setCreateOpen(false);
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '사용자 등록 실패', message: error.message });
})
.finally(() => setCreateSaving(false));
};
return (
<>
<PageTitle eyebrow="운영" title="사용자/세션" description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다." />
<PageTitle
eyebrow="운영"
title="사용자/세션"
description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다."
actions={
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
<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">
사용자/세션 데이터를 불러오지 못했습니다.
@@ -202,6 +285,96 @@ export function UserSessionsPage() {
</div>
) : null}
</Modal>
<Modal
open={createOpen}
title="사용자 추가"
onClose={() => {
if (!createSaving) setCreateOpen(false);
}}
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={createSaving}>취소</Button>
<Button
onClick={submitCreateUser}
disabled={createSaving || !createForm.email.trim() || !createForm.name.trim() || createForm.password.length < 8}
>
{createSaving ? '등록 중' : '등록'}
</Button>
</>
}
>
<div className="space-y-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={createForm.email}
onChange={(event) => setCreateForm((current) => ({ ...current, 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={createForm.name}
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
/>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호 <b className="text-foreground">*</b></span>
<input
type="password"
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={createForm.password}
onChange={(event) => setCreateForm((current) => ({ ...current, password: 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={createForm.organizationId}
onChange={(event) => setCreateForm((current) => ({ ...current, organizationId: event.target.value }))}
>
<option value="">없음</option>
{organizations.map((organization) => (
<option key={organization.id} value={organization.id}>{organization.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
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={createForm.department}
onChange={(event) => setCreateForm((current) => ({ ...current, department: 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={createForm.position}
onChange={(event) => setCreateForm((current) => ({ ...current, position: event.target.value }))}
/>
</label>
</div>
<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={createForm.licenseCode}
onChange={(event) => setCreateForm((current) => ({ ...current, licenseCode: event.target.value as 'FREE' | 'EDU' | 'PRO' }))}
>
{licensePlans.map((plan) => (
<option key={plan.code} value={plan.code}>{plan.name}</option>
))}
{licensePlans.length === 0 ? <option value="EDU">EDU</option> : null}
</select>
</label>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);