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:
@@ -171,7 +171,7 @@ export function AdminManagementPage() {
|
||||
};
|
||||
|
||||
const submitAdmin = () => {
|
||||
if (!adminForm.email.trim() || !adminForm.name.trim()) return;
|
||||
if (!adminForm.email.trim() || !adminForm.name.trim() || adminForm.temporaryPassword.length < 8) return;
|
||||
setSaveState('saving');
|
||||
createAdmin(adminForm)
|
||||
.then(() => {
|
||||
@@ -396,26 +396,26 @@ export function AdminManagementPage() {
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="관리자 등록"
|
||||
title="관리자 추가"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>취소</Button>
|
||||
<Button onClick={submitAdmin} disabled={saveState === 'saving'}>{saveState === 'saving' ? '저장 중' : '등록'}</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">이메일</span>
|
||||
<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">이름</span>
|
||||
<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">권한</span>
|
||||
<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}>
|
||||
@@ -425,7 +425,7 @@ export function AdminManagementPage() {
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">임시 비밀번호</span>
|
||||
<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>
|
||||
|
||||
@@ -342,7 +342,7 @@ export function LicensesPage() {
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" disabled={users.length === 0 || plans.length === 0} onClick={openAssignModal}>
|
||||
<Plus className="size-4" />
|
||||
라이센스 부여
|
||||
등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -433,7 +433,7 @@ export function LicensesPage() {
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
{editingRow ? '라이센스 수정' : '라이센스 부여'}
|
||||
{editingRow ? '라이센스 수정' : '라이센스 추가'}
|
||||
</span>
|
||||
}
|
||||
onClose={closeAssignModal}
|
||||
@@ -449,7 +449,7 @@ export function LicensesPage() {
|
||||
<div className="space-y-4">
|
||||
{editingRow ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">사용자 <b className="text-foreground">*</b></span>
|
||||
<div className="mt-2 rounded-md border border-border bg-background/80 px-3 py-2 text-sm">
|
||||
<p className="text-foreground">{editingRow.user}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{editingRow.name || '-'}</p>
|
||||
@@ -505,7 +505,7 @@ export function LicensesPage() {
|
||||
</div>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
|
||||
<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={assignForm.licenseCode}
|
||||
@@ -523,7 +523,7 @@ export function LicensesPage() {
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">시작일</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">시작일 <b className="text-foreground">*</b></span>
|
||||
<input
|
||||
type="date"
|
||||
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"
|
||||
|
||||
@@ -340,7 +340,7 @@ export function NoticesPage() {
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
공지사항
|
||||
{editing ? '공지사항 수정' : '공지사항 추가'}
|
||||
</span>
|
||||
}
|
||||
onClose={closeModal}
|
||||
@@ -357,7 +357,7 @@ export function NoticesPage() {
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">대상 앱</span>
|
||||
<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={form.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
|
||||
<option value="Launcher">Launcher</option>
|
||||
{apps.map((app) => (
|
||||
@@ -374,11 +374,11 @@ export function NoticesPage() {
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">제목</span>
|
||||
<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={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">본문</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">본문 <b className="text-foreground">*</b></span>
|
||||
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
|
||||
@@ -220,7 +220,7 @@ export function OrganizationsPage() {
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreateModal}>
|
||||
<Plus className="size-4" />
|
||||
조직 등록
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -273,7 +273,7 @@ export function OrganizationsPage() {
|
||||
})() : null}
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editing ? '조직 수정' : '조직 등록'}
|
||||
title={editing ? '조직 수정' : '조직 추가'}
|
||||
onClose={closeModal}
|
||||
footer={
|
||||
<>
|
||||
@@ -284,7 +284,7 @@ export function OrganizationsPage() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">조직명</span>
|
||||
<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 placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={form.name}
|
||||
@@ -293,7 +293,7 @@ export function OrganizationsPage() {
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">유형</span>
|
||||
<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={form.type}
|
||||
@@ -310,7 +310,6 @@ export function OrganizationsPage() {
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
value={form.domain}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, domain: event.target.value }))}
|
||||
placeholder="yu.ac.kr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,10 @@ export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet
|
||||
|
||||
export function statusTone(status?: string | null): BadgeTone {
|
||||
if (!status) return 'neutral';
|
||||
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
||||
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', '유효토큰', 'Running', 'ACTIVE'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
|
||||
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
|
||||
if (['없음'].includes(status)) return 'neutral';
|
||||
if (['없음', '세션 없음'].includes(status)) return 'neutral';
|
||||
if (['런처'].includes(status)) return 'violet';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function loadUserSessions() {
|
||||
return (response.sessions ?? []).map((session) => ({
|
||||
...session,
|
||||
session: session.sessionId ? session.session : '-',
|
||||
state: session.state === '세션 없음' ? '없음' : session.state
|
||||
state: session.state
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -188,6 +188,21 @@ export function terminateUserSession(sessionId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createManagedUser(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
organizationId?: string | null;
|
||||
department?: string | null;
|
||||
position?: string | null;
|
||||
licenseCode?: 'FREE' | 'EDU' | 'PRO';
|
||||
}) {
|
||||
return apiRequest<{ user: { id: string; email: string; name?: string | null } }>('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadOrganizations() {
|
||||
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
||||
return response.organizations ?? [];
|
||||
|
||||
Reference in New Issue
Block a user