improve app registration and component service ports
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user