diff --git a/README.md b/README.md index 3ceb1033..2216d851 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,10 @@ SPA이고, 데이터는 전부 백엔드 API에서 받아옵니다. - **신청서**: 리소스 종류를 고르고, 만들 리소스(이름과 OS, 사양)를 정한 뒤, 누구 명의로 왜 언제까지 쓸지를 적습니다. 종류를 아는 자리에서 들어오면 첫 단계는 접힙니다. 사양은 준비된 것을 고르거나 직접 적을 수 있고, 직접 적으면 사유를 받아 관리자가 검토합니다. - 접속 이름(SSH 접속명)도 여기서 직접 정할 수 있습니다. + 접속 이름(SSH 접속명)도 여기서 직접 정할 수 있습니다. 워크스페이스 소유자와 승인 권한자는 + 구성원과 대기 중인 초대를 대상자로 골라 가상머신과 LLM API 키를 여러 사람에게 한 번에 + 신청할 수 있고, 초대받은 사람의 리소스는 가입할 때 만들어집니다. 관리자 콘솔에서는 같은 + 신청서로 제출과 동시에 승인하고, 신청 상세에서 대상자별 생성 상태를 확인합니다. - **워크스페이스 스코프**: 사이드바 선택기로 워크스페이스 하나를 고르면 그 워크스페이스가 가진 리소스와 신청을 전부 보고, 접근 권한이 없는 리소스는 이름과 상태, 소유자까지 보입니다. '전체'를 고르면 워크스페이스를 가리지 않고 본인이 접근 권한을 가진 리소스와 diff --git a/src/App.tsx b/src/App.tsx index 91e1ef9c..c39d23b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { AdminSettingsPage } from './pages/AdminSettingsPage' import { AdminTasksPage } from './pages/AdminTasksPage' import { AdminTerminalSessionsPage } from './pages/AdminTerminalSessionsPage' import { AdminRequestDetailPage } from './pages/AdminRequestDetailPage' +import { AdminNewRequestPage } from './pages/AdminNewRequestPage' import { AdminRequestsPage } from './pages/AdminRequestsPage' import { AdminUsersPage } from './pages/AdminUsersPage' import { AdminWorkspacesPage } from './pages/AdminWorkspacesPage' @@ -238,6 +239,7 @@ function App() { } /> } /> + } /> } /> } /> } /> diff --git a/src/api/queries.ts b/src/api/queries.ts index 71a90058..0fec03fc 100644 --- a/src/api/queries.ts +++ b/src/api/queries.ts @@ -27,6 +27,9 @@ export type CreateRequest = Schemas['CreateRequestRequest'] export type RequestDetail = Schemas['RequestDetailResponse'] export type RequestPage = Schemas['PageResponseRequestDetailResponse'] export type RequestStatus = Schemas['RequestStatus'] +export type RequestRecipient = Schemas['RequestRecipientResponse'] +export type RequestRecipientStatus = Schemas['RequestRecipientStatus'] +export type CreateRequestRecipient = Schemas['CreateRequestRecipient'] export type ResourceType = Schemas['ResourceType'] export type VmSummary = Schemas['VmSummaryResponse'] export type VmDetail = Schemas['VmDetailResponse'] @@ -239,6 +242,17 @@ export function fetchWorkspaceInvitations(workspaceId: string): Promise { + return guardNetwork(async () => { + const { data, error } = await api.GET('/admin/workspaces/{workspaceId}/invitations', { + params: { path: { workspaceId } }, + }) + if (!data) throw toApiError(error, '대기 중인 초대를 불러오지 못했습니다.') + return data + }) +} + export function fetchOrgs(): Promise { return guardNetwork(async () => { const { data, error } = await api.GET('/orgs') @@ -2071,7 +2085,20 @@ export function resendAdminNotification(notificationId: string): Promise { +/** Moves a failed recipient of a bulk request back into the creation queue. */ +export function retryRequestRecipient(requestId: string, recipientId: string): Promise { + return guardNetwork(async () => { + const { data, error } = await api.POST('/admin/requests/{requestId}/recipients/{recipientId}/retry', { + params: { path: { requestId, recipientId } }, + }) + if (!data) throw toApiError(error, '다시 시도하지 못했습니다.') + return data + }) +} + +export function fetchAdminWorkspaces( + params: { orgId?: string; all?: boolean } = {}, +): Promise { return guardNetwork(async () => { const { data, error } = await api.GET('/admin/workspaces', { params: { query: params } }) if (!data) throw toApiError(error, '워크스페이스 목록을 불러오지 못했습니다.') diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index 98b1a37a..7e2cd318 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -1311,6 +1311,26 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/requests/{requestId}/recipients/{recipientId}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 대상자 생성 다시 시도 + * @description 여러 사람에게 리소스를 만드는 신청에서 생성에 실패한 대상자를 다시 생성 대기로 돌립니다. 실패한 대상자만 다시 시도할 수 있습니다. + */ + post: operations["retryRequestRecipient"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/requests/{requestId}/reject": { parameters: { query?: never; @@ -1923,6 +1943,26 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/workspaces/{workspaceId}/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 워크스페이스의 대기 중인 초대 목록 + * @description 신청을 승인할 수 있는 관리자가 아직 가입하지 않은 사람을 신청 대상자로 고를 때 씁니다. 대기 중인 초대만 오래된 순서로 돌려줍니다. + */ + get: operations["listAdminWorkspaceInvitations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/auth/login": { parameters: { query?: never; @@ -4763,7 +4803,22 @@ export interface components { endDate: string; name: string; }; + /** @description 리소스를 받을 대상자 한 명. userId와 invitationId 중 정확히 하나를 보냅니다. */ + CreateRequestRecipient: { + /** + * Format: uuid + * @description 워크스페이스의 대기 중인 초대. 초대받은 사람이 가입하면 그때 리소스를 만듭니다. + */ + invitationId?: string | null; + /** + * Format: uuid + * @description 워크스페이스의 활성 구성원. + */ + userId?: string | null; + }; CreateRequestRequest: { + /** @description 제출과 동시에 승인할 때의 승인 내용. 이 기관의 신청을 승인할 수 있는 관리자만 보낼 수 있습니다. 승인 화면에서 보내는 내용과 같습니다. */ + approval?: components["schemas"]["ApproveRequestRequest"] | null; displayName: string; domain?: components["schemas"]["CreateDomainRequestSpec"] | null; extraNote?: string | null; @@ -4777,6 +4832,8 @@ export interface components { /** Format: uuid */ periodPresetId?: string | null; purpose: string; + /** @description 리소스를 받을 대상자. 비우면 신청자 본인이 받는 일반 신청입니다. VM과 LLM API 키에만 쓸 수 있고, 워크스페이스 소유자나 이 기관의 신청을 승인할 수 있는 관리자만 지정할 수 있습니다. 대상자마다 리소스를 하나씩 만들며, 가입 전인 초대 대상자는 가입할 때 만듭니다. */ + recipients?: components["schemas"]["CreateRequestRecipient"][] | null; /** Format: date */ reqEndDate?: string | null; /** @description true면 종료일 없이 신청합니다. reqEndDate·periodPresetId와 함께 보낼 수 없습니다. */ @@ -7535,6 +7592,8 @@ export interface components { */ periodName?: string | null; purpose: string; + /** @description 여러 사람에게 리소스를 만드는 신청의 대상자. 신청자 본인이 받는 일반 신청은 빈 배열입니다. */ + recipients: components["schemas"]["RequestRecipientResponse"][]; /** * Format: date * @description 신청한 사용 종료일. 값이 없으면 무기한을 요청한 것입니다. @@ -7582,6 +7641,29 @@ export interface components { */ id: string; }; + RequestRecipientResponse: { + /** Format: uuid */ + id: string; + /** @description 초대할 때 적은 이메일 또는 학번. 신청자, 워크스페이스 소유자, 승인 권한이 있는 관리자에게만 보입니다. */ + invitee?: string | null; + /** @description 대상 계정의 이름. 가입 전인 초대 대상자는 null입니다. */ + name?: string | null; + /** @description 만들지 않았거나 실패한 이유. */ + reason?: string | null; + /** + * Format: uuid + * @description 만든 리소스. 종류는 신청의 type을 따릅니다. 만들기 전에는 null입니다. + */ + resourceId?: string | null; + status: components["schemas"]["RequestRecipientStatus"]; + /** + * Format: uuid + * @description 대상 계정. 가입 전인 초대 대상자는 null입니다. + */ + userId?: string | null; + }; + /** @enum {string} */ + RequestRecipientStatus: "PENDING_JOIN" | "QUEUED" | "CREATING" | "CREATED" | "SKIPPED_EXPIRED" | "SKIPPED_INELIGIBLE" | "FAILED" | "CANCELED"; RequestReviewResponse: { comment?: string | null; /** Format: date-time */ @@ -11380,6 +11462,38 @@ export interface operations { }; }; }; + retryRequestRecipient: { + parameters: { + query?: never; + header?: never; + path: { + requestId: string; + recipientId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["RequestDetailResponse"]; + }; + }; + /** @description 오류 — 상태 코드와 무관하게 Problem 형태 */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; rejectRequest: { parameters: { query?: never; @@ -12728,6 +12842,8 @@ export interface operations { parameters: { query?: { orgId?: string; + /** @description true면 기관 필터와 무관하게 삭제되지 않은 모든 워크스페이스를 돌려줍니다. 관리자가 대상자를 골라 신청할 워크스페이스를 고를 때 씁니다. */ + all?: boolean; }; header?: never; path?: never; @@ -12786,6 +12902,37 @@ export interface operations { }; }; }; + listAdminWorkspaceInvitations: { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["WorkspaceInvitationResponse"][]; + }; + }; + /** @description 오류 — 상태 코드와 무관하게 Problem 형태 */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; login: { parameters: { query?: never; diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index 89d1642d..881c864a 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -81,6 +81,28 @@ export function canDecideRequest(role: UserRole): boolean { return role === 'ORG_ADMIN' || role === 'ORG_MANAGER' || role === 'SYS_ADMIN' } +/** + * May this account decide requests filed to organisation `orgId`? The decision + * roles act only in the organisations they operate; SYS_ADMIN acts in all. + * The same question decides who may file a request for other people and who + * may submit one already approved. + */ +export function approvesForOrg( + user: { role: UserRole; managedOrgs: readonly ManagedOrg[] } | null | undefined, + orgId: string | null | undefined, +): boolean { + if (!user || orgId == null || !canDecideRequest(user.role)) return false + return user.role === 'SYS_ADMIN' || operatesOrg(user.managedOrgs, orgId) +} + +/** Does this account decide requests in at least one organisation? */ +export function approvesAnywhere( + user: { role: UserRole; managedOrgs: readonly ManagedOrg[] } | null | undefined, +): boolean { + if (!user || !canDecideRequest(user.role)) return false + return user.role === 'SYS_ADMIN' || operatedOrgs(user.managedOrgs).length > 0 +} + /** Schedule / cancel a VM deletion (§3.11). ORG_ADMIN + SYS_ADMIN only. */ export function canManageVmDeletion(role: UserRole): boolean { return role === 'ORG_ADMIN' || role === 'SYS_ADMIN' diff --git a/src/components/request-kind/KindPicker.tsx b/src/components/request-kind/KindPicker.tsx index 144d3446..3d4281c5 100644 --- a/src/components/request-kind/KindPicker.tsx +++ b/src/components/request-kind/KindPicker.tsx @@ -6,6 +6,7 @@ import { gpuRequestKind } from './gpu-wizard' import { gpuPreviewEnabled } from '../../lib/gpu-preview' import { useScope } from '../../lib/use-scope' import { consolePaths } from '../../lib/paths' +import type { RequestKindModule } from './types' /** * 무엇을 신청할지 고르는 화면. @@ -18,9 +19,18 @@ import { consolePaths } from '../../lib/paths' * 화면 밖으로 나가는 것은 주소 하나뿐이다. 고르면 `?kind=`를 실은 같은 경로로 * 이동하고, 그 뒤로는 어느 진입이든 똑같은 3단계 위저드다. */ -export function KindPicker() { +export function KindPicker({ + kinds, + pathFor, +}: { + /** The kinds offered; the applicant's full list when absent. */ + kinds?: RequestKindModule[] + /** Where choosing a kind goes; the applicant's wizard when absent. */ + pathFor?: (type: string) => string +} = {}) { const navigate = useNavigate() const scope = useScope() + const offered = kinds ?? [...REQUEST_KINDS, ...(gpuPreviewEnabled() ? [gpuRequestKind] : [])] return (
@@ -34,8 +44,8 @@ export function KindPicker() { navigate(consolePaths.newRequest(scope, type))} - options={[...REQUEST_KINDS, ...(gpuPreviewEnabled() ? [gpuRequestKind] : [])].map((entry) => ({ + onChange={(type) => navigate(pathFor ? pathFor(type) : consolePaths.newRequest(scope, type))} + options={offered.map((entry) => ({ value: entry.type, title: entry.picker.title, description: entry.picker.description, diff --git a/src/components/request-kind/RecipientPicker.tsx b/src/components/request-kind/RecipientPicker.tsx new file mode 100644 index 00000000..57566484 --- /dev/null +++ b/src/components/request-kind/RecipientPicker.tsx @@ -0,0 +1,68 @@ +import { Alert, Checkbox, Spinner } from '../ui' +import type { RecipientCandidate } from './recipients' + +export function RecipientPicker({ + candidates, + loading, + error, + selected, + onChange, + required, + description, + fieldError, +}: { + candidates: RecipientCandidate[] + loading: boolean + error: string | null + selected: readonly string[] + onChange: (next: string[]) => void + required: boolean + description?: string + fieldError?: string +}) { + const chosen = new Set(selected) + const allChosen = candidates.length > 0 && candidates.every((candidate) => chosen.has(candidate.key)) + const toggle = (key: string, on: boolean) => + onChange(on ? [...selected.filter((k) => k !== key), key] : selected.filter((k) => k !== key)) + + return ( +
+ + 대상자 + {required && } + + {description &&

{description}

} + {fieldError &&

{fieldError}

} + {loading ? ( +
+ +
+ ) : error ? ( + {error} + ) : candidates.length === 0 ? ( +

고를 수 있는 사람이 없습니다.

+ ) : ( +
+ + onChange(event.target.checked ? candidates.map((candidate) => candidate.key) : []) + } + /> +
+ {candidates.map((candidate) => ( + toggle(candidate.key, event.target.checked)} + /> + ))} +
+
+ )} +
+ ) +} diff --git a/src/components/request-kind/RecipientsCard.tsx b/src/components/request-kind/RecipientsCard.tsx new file mode 100644 index 00000000..81b397b8 --- /dev/null +++ b/src/components/request-kind/RecipientsCard.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { Link } from 'react-router' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + retryRequestRecipient, + type RequestDetail, + type RequestRecipient, +} from '../../api/queries' +import { + Alert, + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + DataTable, + TBody, + TD, + TH, + THead, + TR, + type BadgeVariant, +} from '../ui' +import { REQUEST_RECIPIENT_STATUS_LABELS } from '../../lib/status' + +const RECIPIENT_STATUS_VARIANTS: Record = { + PENDING_JOIN: 'neutral', + QUEUED: 'info', + CREATING: 'info', + CREATED: 'success', + SKIPPED_EXPIRED: 'warning', + SKIPPED_INELIGIBLE: 'warning', + FAILED: 'danger', + CANCELED: 'neutral', +} + +/** A status this build does not know is shown as the raw value rather than dropped. */ +export function RecipientStatusBadge({ status }: { status: string }) { + const labels: Record = REQUEST_RECIPIENT_STATUS_LABELS + return ( + + {labels[status] ?? status} + + ) +} + +function recipientLabel(recipient: RequestRecipient): string { + return recipient.name ?? recipient.invitee ?? '—' +} + +/** + * The people a request was filed for and what became of each. Rendered only + * for a request that names recipients; an ordinary request has none. + */ +export function RequestRecipientsCard({ + request, + resourceHref, + canRetry, +}: { + request: RequestDetail + /** Where the created resource opens for this viewer, or null when it is not theirs to open. */ + resourceHref: (recipient: RequestRecipient) => string | null + canRetry: boolean +}) { + const queryClient = useQueryClient() + const [error, setError] = useState(null) + const retry = useMutation({ + mutationFn: (recipientId: string) => retryRequestRecipient(request.id, recipientId), + onSuccess: async () => { + setError(null) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin', 'requests'] }), + queryClient.invalidateQueries({ queryKey: ['requests'] }), + ]) + }, + onError: (err) => setError(err.message), + }) + + const recipients = request.recipients ?? [] + if (recipients.length === 0) return null + const retryable = canRetry && recipients.some((recipient) => recipient.status === 'FAILED') + + return ( + + + 대상자 + + + {error && {error}} + + + + 대상자 + 상태 + 리소스 + 사유 + {retryable && ( + + 작업 + + )} + + + + {recipients.map((recipient) => { + const href = recipient.resourceId ? resourceHref(recipient) : null + return ( + + {recipientLabel(recipient)} + + + + + {href ? ( + + 상세 보기 + + ) : ( + '—' + )} + + {recipient.reason ?? '—'} + {retryable && ( + + {recipient.status === 'FAILED' && ( + + )} + + )} + + ) + })} + + + + + ) +} diff --git a/src/components/request-kind/RequestWizard.tsx b/src/components/request-kind/RequestWizard.tsx index accf7312..919e84e1 100644 --- a/src/components/request-kind/RequestWizard.tsx +++ b/src/components/request-kind/RequestWizard.tsx @@ -4,12 +4,22 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../../api/client' import { toApiError } from '../../api/problem' import { + fetchAdminWorkspace, + fetchAdminWorkspaceInvitations, + fetchAdminWorkspaces, fetchOrgs, fetchRequestPeriods, + fetchWorkspace, + fetchWorkspaceInvitations, fetchWorkspaces, + type ApproveRequest, type CreateRequest, type RequestDetail, + type WorkspaceMemberRole, + type WorkspaceSummary, } from '../../api/queries' +import { useAuth } from '../../auth/auth-context' +import { approvesForOrg } from '../../auth/permissions' import { Alert, Button, @@ -29,9 +39,12 @@ import { import { fieldErrorsOf } from '../../lib/field-errors' import { kstDateString, todayKstDate } from '../../lib/format' import { clearDraft, loadDraft, saveDraft } from '../../lib/request-draft' -import { consolePaths } from '../../lib/paths' +import { adminPaths, consolePaths } from '../../lib/paths' import { useScope } from '../../lib/use-scope' import { ReviewStep, type ReviewSection } from './ReviewStep' +import { RecipientPicker } from './RecipientPicker' +import { recipientBody, type RecipientCandidate } from './recipients' +import { SubmitApproval } from './SubmitApproval' import { COMMON_FIELDS, fieldLabels, @@ -43,7 +56,12 @@ import { type FieldSlot, type WizardStepId, } from './wizard-steps' -import type { CommonWizardState, FieldErrors, RequestKindModule } from './types' +import type { + CommonWizardState, + DecisionFormApi, + FieldErrors, + RequestKindModule, +} from './types' const INITIAL_COMMON: CommonWizardState = { workspaceId: null, @@ -55,6 +73,21 @@ const INITIAL_COMMON: CommonWizardState = { indefinite: false, reqEndDate: '', displayName: '', + recipients: [], +} + +/** A workspace the wizard can file into, whichever list it came from. */ +interface WorkspaceOption { + id: string + name: string + kind: WorkspaceSummary['kind'] + /** The viewer's own role; absent on the administrator's list. */ + myRole?: WorkspaceMemberRole +} + +/** Server paths `recipients[3]` and `recipients[3].userId` belong to the one picker. */ +function normalizeFieldKey(field: string): string { + return /^recipients(\[|\.|$)/.test(field) ? 'recipients' : field } /** 직접 적는 종료일의 상한. 서버와 같은 값이다. */ @@ -62,26 +95,138 @@ const MAX_CUSTOM_PERIOD_YEARS = 2 export function RequestWizard({ kind, + admin, }: { kind: RequestKindModule + /** + * Present when an approver files from the administration area: the request + * is for workspace members they pick and is approved as it is submitted. + * `orgId` is the organisation the administration area is scoped to. + */ + admin?: { orgId?: string } }) { const scope = useScope() - const workspaces = useQuery({ queryKey: ['workspaces'], queryFn: fetchWorkspaces }) + const { user } = useAuth() + const adminMode = admin != null + const workspaces = useQuery({ + queryKey: ['workspaces'], + queryFn: fetchWorkspaces, + enabled: !adminMode, + }) const orgs = useQuery({ queryKey: ['orgs'], queryFn: fetchOrgs }) const periods = useQuery({ queryKey: ['request-periods'], queryFn: fetchRequestPeriods }) const queryClient = useQueryClient() const [searchParams, setSearchParams] = useSearchParams() - const [draft] = useState(loadDraft) - const [state, setState] = useState(() => ({ - ...INITIAL_COMMON, - ...draft.common, - // 스코프에서 들어왔다면 그 워크스페이스를 채운다. 초안이 이미 답한 경우에는 - // 덮지 않는다. 스코프는 지금 무엇을 보고 있었는지일 뿐이고 초안은 사용자가 - // 실제로 고른 것이다. - workspaceId: draft.common.workspaceId ?? scope, - })) - const kindApi = kind.useWizard(draft.kindType === kind.type ? draft.spec : null, state) + // The administration entry keeps no draft: the session draft is the + // applicant's own form, and its workspace is one from their own list. + const [draft] = useState(() => + adminMode ? { kindType: kind.type, common: {}, spec: null } : loadDraft(), + ) + const [state, setState] = useState(() => + adminMode + ? { + ...INITIAL_COMMON, + orgId: approvesForOrg(user, admin.orgId) ? (admin.orgId ?? null) : null, + } + : { + ...INITIAL_COMMON, + ...draft.common, + recipients: draft.common.recipients ?? [], + // 스코프에서 들어왔다면 그 워크스페이스를 채운다. 초안이 이미 답한 경우에는 + // 덮지 않는다. 스코프는 지금 무엇을 보고 있었는지일 뿐이고 초안은 사용자가 + // 실제로 고른 것이다. + workspaceId: draft.common.workspaceId ?? scope, + }, + ) + + // Every live workspace, personal ones included: an approver may file into + // any of them for the organisation it decides for. + const adminWorkspaces = useQuery({ + queryKey: ['admin', 'workspaces', { all: true }], + queryFn: () => fetchAdminWorkspaces({ all: true }), + enabled: adminMode && state.orgId != null, + }) + const eligibleWorkspaces: WorkspaceOption[] = adminMode + ? (adminWorkspaces.data ?? []) + : (workspaces.data ?? []) + const selectedWorkspace = eligibleWorkspaces.find((w) => w.id === state.workspaceId) + + // Who the request may name. A workspace owner and an approver for the chosen + // organisation may file for others; anyone else files for themselves. + const approver = approvesForOrg(user, state.orgId) + const ownsWorkspace = selectedWorkspace?.myRole === 'OWNER' + const recipientsOffered = + !!kind.supportsRecipients && + selectedWorkspace != null && + // A personal workspace holds only its owner, who files for themselves; + // an approver filing into it names that owner. + (adminMode || (selectedWorkspace.kind !== 'PERSONAL' && (ownsWorkspace || approver))) + const members = useQuery({ + queryKey: ['workspaces', state.workspaceId], + queryFn: () => fetchWorkspace(state.workspaceId!), + enabled: !adminMode && recipientsOffered, + }) + // Pending invitations are listed to the owner only. + const invitations = useQuery({ + queryKey: ['workspaces', state.workspaceId, 'invitations'], + queryFn: () => fetchWorkspaceInvitations(state.workspaceId!), + enabled: !adminMode && recipientsOffered && ownsWorkspace, + }) + const adminMembers = useQuery({ + queryKey: ['admin', 'workspaces', 'detail', state.workspaceId], + queryFn: () => fetchAdminWorkspace(state.workspaceId!), + enabled: adminMode && recipientsOffered, + }) + // People invited but not yet joined get their resource when they join, so + // the approver has to be able to name them too. + const adminInvitations = useQuery({ + queryKey: ['admin', 'workspaces', 'detail', state.workspaceId, 'invitations'], + queryFn: () => fetchAdminWorkspaceInvitations(state.workspaceId!), + enabled: adminMode && recipientsOffered, + }) + const candidates: RecipientCandidate[] = adminMode + ? [ + ...(adminMembers.data?.members ?? []) + .filter((member) => member.userStatus === 'ACTIVE') + .map((member) => ({ key: `u:${member.userId}`, label: member.name, description: member.email })), + ...(adminInvitations.data ?? []).map((invitation) => ({ + key: `i:${invitation.id}`, + label: invitation.email ?? invitation.studentNo ?? '—', + description: '가입하면 만들어집니다.', + })), + ] + : [ + ...(members.data?.members ?? []).map((member) => ({ + key: `u:${member.userId}`, + label: member.name, + description: member.email, + })), + ...(ownsWorkspace ? (invitations.data ?? []) : []).map((invitation) => ({ + key: `i:${invitation.id}`, + label: invitation.email ?? invitation.studentNo ?? '—', + description: '가입하면 만들어집니다.', + })), + ] + const candidatesLoading = adminMode + ? adminMembers.isPending || adminInvitations.isPending + : members.isPending || (ownsWorkspace && invitations.isPending) + const candidatesError = + (adminMode + ? (adminMembers.error ?? adminInvitations.error) + : (members.error ?? (ownsWorkspace ? invitations.error : null))) + ?.message ?? null + // A key left over from a draft or from another workspace names no one on the + // list, so it is not chosen. + const selectedRecipients = recipientsOffered + ? candidates.filter((candidate) => state.recipients.includes(candidate.key)) + : [] + const bulk = selectedRecipients.length > 0 + + const kindApi = kind.useWizard(draft.kindType === kind.type ? draft.spec : null, state, { bulk }) + const approvalHandle = useRef(null) + const [approvalErrors, setApprovalErrors] = useState>({}) + const [confirmOpen, setConfirmOpen] = useState(false) // Which common controls this kind does not use. Read once here so the three // places that have to agree — what is drawn, what is validated, what the @@ -105,13 +250,16 @@ export function RequestWizard({ setState((prev) => ({ ...prev, ...patch })) const isLoading = - workspaces.isPending || orgs.isPending || periods.isPending || kindApi.isPending - const loadError = workspaces.error ?? orgs.error ?? periods.error ?? kindApi.error + (!adminMode && workspaces.isPending) || orgs.isPending || periods.isPending || kindApi.isPending + const loadError = + (adminMode ? null : workspaces.error) ?? orgs.error ?? periods.error ?? kindApi.error const ready = !isLoading && !loadError - const eligibleWorkspaces = workspaces.data ?? [] - const selectedWorkspace = eligibleWorkspaces.find((w) => w.id === state.workspaceId) - const selectedOrg = orgs.data?.find((o) => o.id === state.orgId) + // An approver files only into organisations they decide for. + const offeredOrgs = adminMode + ? (orgs.data ?? []).filter((org) => approvesForOrg(user, org.id)) + : (orgs.data ?? []) + const selectedOrg = offeredOrgs.find((o) => o.id === state.orgId) const offeredPeriods = periods.data ?? [] const selectedPeriod = offeredPeriods.find((p) => p.id === state.periodPresetId) @@ -128,6 +276,8 @@ export function RequestWizard({ next.workspaceId = '신청할 워크스페이스를 선택해 주세요.' if (!hidden.orgId && (state.orgId == null || !selectedOrg)) next.orgId = '기관을 선택해 주세요.' + if (adminMode && selectedRecipients.length === 0) + next.recipients = '대상자를 한 명 이상 선택해 주세요.' if (!state.purpose.trim()) next.purpose = '사용 목적을 입력해 주세요.' else if (state.purpose.length > 2000) next.purpose = '사용 목적은 2000자 이하로 입력해 주세요.' @@ -205,13 +355,14 @@ export function RequestWizard({ }, [serverFieldErrors, step]) useEffect(() => { - if (submitted) return + if (submitted || adminMode) return saveDraft(kind.type, state, kindApi.spec) - }, [kind.type, kindApi.spec, state, submitted]) + }, [adminMode, kind.type, kindApi.spec, state, submitted]) // 입력이 바뀌면 서버 오류를 지운다. 호스트 이름을 다시 치는 순간 문구가 사라진다. useEffect(() => { setServerFieldErrors((prev) => (Object.keys(prev).length === 0 ? prev : {})) + setApprovalErrors((prev) => (Object.keys(prev).length === 0 ? prev : {})) setSubmitError(null) setReturnedFrom(null) }, [state, kindApi.spec]) @@ -223,8 +374,10 @@ export function RequestWizard({ return data }, onSuccess: (data) => { - clearDraft() + setConfirmOpen(false) + if (!adminMode) clearDraft() void queryClient.invalidateQueries({ queryKey: ['requests'] }) + void queryClient.invalidateQueries({ queryKey: ['admin', 'requests'] }) // `step`만 뺀다. 통째로 비우면 `kind`가 함께 날아가고, 종류를 잃은 주소는 // 위저드가 아니라 종류 고르기 화면이라 성공 화면이 마운트되지도 못한다. setSearchParams( @@ -238,9 +391,18 @@ export function RequestWizard({ setSubmitted(data) }, onError: (err) => { + setConfirmOpen(false) const apiError = toApiError(err, '신청을 제출하지 못했습니다.') - const mapped = fieldErrorsOf(apiError.problem) + // The approval half answers under `approval.`; its form uses the same keys + // without the prefix, as the approval screen does. + const mapped: Record = {} + const approvalMapped: Record = {} + for (const [field, message] of Object.entries(fieldErrorsOf(apiError.problem))) { + if (field.startsWith('approval.')) approvalMapped[field.slice('approval.'.length)] ??= message + else mapped[normalizeFieldKey(field)] ??= message + } setServerFieldErrors(mapped) + setApprovalErrors(approvalMapped) setSubmitError(apiError.message) const owner = routeServerErrors(mapped, fields, steps) if (owner && owner !== 'review') { @@ -250,7 +412,13 @@ export function RequestWizard({ }, }) - if (submitted) return + if (submitted) { + return adminMode ? ( + + ) : ( + + ) + } if (isLoading) { return ( @@ -292,8 +460,78 @@ export function RequestWizard({ reqIndefinite: state.periodMode === 'custom' && state.indefinite ? true : null, displayName, ...kindApi.payload(), + // Left out rather than sent empty: a request without recipients is the + // ordinary request it always was. + ...(bulk ? { recipients: selectedRecipients.map((candidate) => recipientBody(candidate.key)) } : {}), }) + const requestedEndDate = + state.periodMode === 'preset' + ? (selectedPeriod?.endDate ?? null) + : state.indefinite + ? null + : state.reqEndDate || null + + /** + * The request as the approval form would read it once filed. The form was + * written against a stored request, and prefilling from this one gives the + * approver the same starting point the approval screen would. + */ + const pendingRequest = (): RequestDetail => { + const payload = buildPayload() + return { + id: '', + createdAt: '', + updatedAt: '', + status: 'SUBMITTED', + review: null, + type: payload.type, + displayName: payload.displayName, + purpose: payload.purpose, + extraNote: payload.extraNote ?? null, + orgId: payload.orgId ?? null, + orgName: selectedOrg?.name ?? '', + workspaceId: payload.workspaceId, + workspaceName: selectedWorkspace?.name ?? '', + requesterId: user?.id ?? null, + requesterName: user?.name ?? '', + reqEndDate: requestedEndDate, + periodName: state.periodMode === 'preset' ? (selectedPeriod?.displayName ?? null) : null, + vm: payload.vm + ? { + imageId: payload.vm.imageId, + imageName: '', + flavorId: payload.vm.flavorId ?? null, + flavorName: null, + reqVcpu: payload.vm.reqVcpu, + reqMemoryMb: payload.vm.reqMemoryMb, + reqDiskGb: payload.vm.reqDiskGb, + specReason: payload.vm.specReason ?? null, + desiredSlug: payload.vm.desiredSlug ?? null, + } + : null, + llmKey: payload.llmKey + ? { + reqRpm: payload.llmKey.reqRpm ?? null, + reqTpm: payload.llmKey.reqTpm ?? null, + reqDailyTokens: payload.llmKey.reqDailyTokens ?? null, + reqCreditLimit: payload.llmKey.reqCreditLimit ?? null, + useCampusModels: payload.llmKey.useCampusModels ?? false, + useCommercialModels: payload.llmKey.useCommercialModels ?? false, + grantedCreditAllowedModels: [], + grantedCreditDeniedModels: [], + grantedPassthroughEndpoints: [], + } + : null, + // Approval puts a member in the creation queue and an invitation in wait + // for its person to join, as the server does. + recipients: selectedRecipients.map((candidate) => ({ + id: candidate.key, + status: candidate.key.startsWith('i:') ? 'PENDING_JOIN' : 'QUEUED', + })), + } + } + const onSubmit = () => { for (const candidate of steps.slice(0, -1)) { const stepErrors = validateStep(candidate) @@ -305,9 +543,23 @@ export function RequestWizard({ } setSubmitError(null) setServerFieldErrors({}) + if (adminMode) { + // The approval form is mounted with the review step; until its catalogue + // loads there is nothing to confirm. + const form = approvalHandle.current + if (!form) return + const approvalProblems = form.validate() + setApprovalErrors(approvalProblems) + if (Object.keys(approvalProblems).length > 0) return + setConfirmOpen(true) + return + } submit.mutate(buildPayload()) } + const submitApproved = (approval: ApproveRequest) => + submit.mutate({ ...buildPayload(), approval }) + const shown: FieldErrors = { ...errors, ...serverFieldErrors } const periodLabel = state.periodMode === 'preset' ? selectedPeriod @@ -326,6 +578,14 @@ export function RequestWizard({ request: [ ...(hidden.orgId ? [] : [['기관', selectedOrg?.name ?? '—'] as [string, string]]), ['워크스페이스', selectedWorkspace?.name ?? '—'], + ...(bulk + ? [ + [ + '대상자', + `${selectedRecipients.length}명 (${selectedRecipients.map((c) => c.label).join(', ')})`, + ] as [string, string], + ] + : []), ['사용 목적', state.purpose.trim()], ...(hidden.period ? [] : [['사용 기간', periodLabel] as [string, string]]), ['참고 사항', state.extraNote.trim() || '—'], @@ -352,7 +612,10 @@ export function RequestWizard({
종류 {kind.picker.title} - + 변경
@@ -408,7 +671,7 @@ export function RequestWizard({ {step === 'request' && ( <> - {hidden.orgId ? null : orgs.data?.length === 0 ? ( + {hidden.orgId ? null : offeredOrgs.length === 0 ? ( 신청할 수 있는 기관이 없습니다. 관리자에게 문의해 주세요. @@ -419,14 +682,18 @@ export function RequestWizard({ error={shown.orgId} description="이 기관이 자원을 제공하고 신청을 검토합니다." value={state.orgId} - onChange={(value) => update({ orgId: value })} - options={(orgs.data ?? []).map((org) => ({ + onChange={(value) => + // The administrator's workspace list is the organisation's, + // so a new organisation clears the workspace and its people. + update(adminMode ? { orgId: value, workspaceId: null, recipients: [] } : { orgId: value }) + } + options={offeredOrgs.map((org) => ({ value: org.id, title: org.name, }))} /> )} - {eligibleWorkspaces.length === 0 && ( + {!adminMode && eligibleWorkspaces.length === 0 && ( {kind.copy.noWorkspaceNotice}{' '} @@ -434,17 +701,42 @@ export function RequestWizard({ )} - update({ workspaceId: value })} - options={eligibleWorkspaces.map((workspace) => ({ - value: workspace.id, - title: workspace.name, - }))} - /> + {adminMode && state.orgId != null && adminWorkspaces.isPending ? ( +
+ +
+ ) : adminMode && adminWorkspaces.isError ? ( + {adminWorkspaces.error.message} + ) : adminMode && state.orgId != null && eligibleWorkspaces.length === 0 ? ( + 이 기관에는 신청할 수 있는 워크스페이스가 없습니다. + ) : ( + (!adminMode || state.orgId != null) && ( + update({ workspaceId: value, recipients: [] })} + options={eligibleWorkspaces.map((workspace) => ({ + value: workspace.id, + title: workspace.name, + }))} + /> + ) + )} + + {recipientsOffered && ( + update({ recipients: next })} + required={adminMode} + description={adminMode ? undefined : '고르지 않으면 본인에게 신청합니다.'} + fieldError={shown.recipients} + /> + )}