= {
orgId: { label: '기관', step: 'request' },
workspaceId: { label: '워크스페이스', step: 'request' },
+ recipients: { label: '대상자', step: 'request' },
purpose: { label: '사용 목적', step: 'request' },
extraNote: { label: '참고 사항', step: 'request' },
periodPresetId: { label: '사용 기간', step: 'request' },
diff --git a/src/docs/articles/getting-started.tsx b/src/docs/articles/getting-started.tsx
index 87e5eb12..5ec99e39 100644
--- a/src/docs/articles/getting-started.tsx
+++ b/src/docs/articles/getting-started.tsx
@@ -479,7 +479,8 @@ export const gettingStartedArticles: GuideArticle[] = [
워크스페이스 구성원이 되면 그 공간에서 리소스를 신청할 수 있습니다. 기존 리소스의
상세 조회나 접속은 해당 리소스의 접근 권한이 있어야 합니다. 새로 승인된 리소스는
- 신청자가 소유자로 시작합니다.
+ 신청자가 소유자로 시작합니다. 대상자를 골라 낸 신청이면 대상자마다 자기 리소스의
+ 소유자가 됩니다.
워크스페이스 소유자는 소속 리소스의 목록을 확인하고 접근 권한과 삭제를 관리할 수
diff --git a/src/lib/paths.ts b/src/lib/paths.ts
index a417cb13..817dc4bc 100644
--- a/src/lib/paths.ts
+++ b/src/lib/paths.ts
@@ -58,6 +58,8 @@ export function adminPath(path: string, orgId: string | undefined): string {
export const adminPaths = {
dashboard: (orgId?: string) => adminPath('/admin', orgId),
requests: (orgId?: string) => adminPath('/admin/requests', orgId),
+ newRequest: (orgId?: string, kind?: string) =>
+ adminPath(kind ? `/admin/requests/new?kind=${kind}` : '/admin/requests/new', orgId),
requestDetail: (requestId: string, orgId?: string) =>
adminPath(`/admin/requests/${requestId}`, orgId),
vms: (orgId?: string, workspaceId?: string | null) =>
diff --git a/src/lib/status.ts b/src/lib/status.ts
index 4e3e2d00..e7d6aa0d 100644
--- a/src/lib/status.ts
+++ b/src/lib/status.ts
@@ -1,6 +1,7 @@
import type { components } from '../api/schema'
export type RequestStatus = components['schemas']['RequestStatus']
+export type RequestRecipientStatus = components['schemas']['RequestRecipientStatus']
export type VmStatus = components['schemas']['VmStatus']
export const REQUEST_STATUS_LABELS: Record = {
@@ -10,6 +11,22 @@ export const REQUEST_STATUS_LABELS: Record = {
CANCELED: '취소됨',
}
+/**
+ * Per-recipient creation state of a request filed for several people. The
+ * value is typed as a plain string at the lookup site so a status this build
+ * does not know falls back to the raw value instead of an empty badge.
+ */
+export const REQUEST_RECIPIENT_STATUS_LABELS: Record = {
+ PENDING_JOIN: '가입 대기',
+ QUEUED: '생성 대기',
+ CREATING: '생성 중',
+ CREATED: '생성됨',
+ SKIPPED_EXPIRED: '기간 지나 생성 안 함',
+ SKIPPED_INELIGIBLE: '대상 아님',
+ FAILED: '실패',
+ CANCELED: '취소됨',
+}
+
export const VM_STATUS_LABELS: Record = {
CREATING: '생성 중',
RUNNING: '실행 중',
diff --git a/src/pages/AdminNewRequestPage.tsx b/src/pages/AdminNewRequestPage.tsx
new file mode 100644
index 00000000..e8e2a5b1
--- /dev/null
+++ b/src/pages/AdminNewRequestPage.tsx
@@ -0,0 +1,34 @@
+import { useSearchParams } from 'react-router'
+import { useAuth } from '../auth/auth-context'
+import { approvesAnywhere } from '../auth/permissions'
+import { Alert } from '../components/ui'
+import { requestKind, REQUEST_KINDS } from '../components/request-kind'
+import { KindPicker } from '../components/request-kind/KindPicker'
+import { RequestWizard } from '../components/request-kind/RequestWizard'
+import { adminPaths } from '../lib/paths'
+import { useAdminScope } from '../lib/use-admin-scope'
+
+/** The kinds an approver can file for workspace members. */
+const ADMIN_KINDS = REQUEST_KINDS.filter((kind) => kind.supportsRecipients)
+
+/**
+ * An approver files a request for members of a workspace and approves it in
+ * the same step. It is the applicant's wizard, not a second creation path:
+ * the request, its review and the per-person creation are the ones any
+ * request goes through.
+ */
+export function AdminNewRequestPage() {
+ const { user } = useAuth()
+ const { activeOrgId } = useAdminScope()
+ const [searchParams] = useSearchParams()
+ if (!approvesAnywhere(user)) {
+ return 신청을 승인할 수 있는 기관이 없습니다.
+ }
+ const found = requestKind(searchParams.get('kind') ?? '')
+ const kind = found?.supportsRecipients ? found : undefined
+ return kind ? (
+
+ ) : (
+ adminPaths.newRequest(activeOrgId, type)} />
+ )
+}
diff --git a/src/pages/AdminRequestDetailPage.tsx b/src/pages/AdminRequestDetailPage.tsx
index 97e6286e..da9acdaf 100644
--- a/src/pages/AdminRequestDetailPage.tsx
+++ b/src/pages/AdminRequestDetailPage.tsx
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
import { toApiError } from '../api/problem'
import { useAuth } from '../auth/auth-context'
-import { canDecideRequest, isSysTier, operatesOrg } from '../auth/permissions'
+import { approvesForOrg } from '../auth/permissions'
import {
fetchAdminRequest,
fetchApprovalContext,
@@ -37,6 +37,8 @@ import {
useDecisionCatalogPrefetch,
} from '../components/request-kind'
import { Field } from '../components/request-kind/Field'
+import { RequestRecipientsCard } from '../components/request-kind/RecipientsCard'
+import { recipientPollInterval } from '../components/request-kind/recipients'
import { RESOURCE_TYPES } from '../components/resource/registry'
import type { RequestKindView } from '../components/request-kind/types'
import { cn } from '../lib/cn'
@@ -59,16 +61,13 @@ export function AdminRequestDetailPage() {
const idValid = isUuid(requestId)
const [notice, setNotice] = useState(null)
const { user } = useAuth()
- // 승인과 반려는 기관 운영 역할 + SYS_ADMIN만 — SYS_MANAGER와 열람 역할은
- // 조회만(§3.9 †15). 역할이 닿아도 이 신청의 기관에서 행위할 수 있어야 한다:
- // 열람 역할로만 보이는 기관의 신청에 승인을 시도하면 API가 404로 거부한다.
- const roleCanDecide = !!user && canDecideRequest(user.role)
const request = useQuery({
queryKey: ['admin', 'requests', requestId, { orgId: activeOrgId ?? null }],
queryFn: () => fetchAdminRequest(requestId),
// 형식부터 틀린 주소는 서버에 물어볼 것이 없다.
enabled: idValid,
+ refetchInterval: (query) => recipientPollInterval(query.state.data),
})
// 결정 폼이 열릴 때 종류별 카탈로그가 이미 와 있도록 진입 즉시 당겨 둔다.
useDecisionCatalogPrefetch()
@@ -99,11 +98,11 @@ export function AdminRequestDetailPage() {
}
// 신청 내용·검토 결과·결정 폼의 종류별 부분은 전부 이 모듈이 답한다.
const kind = requestKindView(data.type)
- const canDecide =
- roleCanDecide &&
- !!user &&
- (isSysTier(user.role) ||
- (data.orgId != null && operatesOrg(user.managedOrgs, data.orgId)))
+ // 승인과 반려는 기관 운영 역할 + SYS_ADMIN만 — SYS_MANAGER와 열람 역할은
+ // 조회만(§3.9 †15). 역할이 닿아도 이 신청의 기관에서 행위할 수 있어야 한다:
+ // 열람 역할로만 보이는 기관의 신청에 승인을 시도하면 API가 404로 거부한다.
+ const canDecide = approvesForOrg(user, data.orgId)
+ const bulk = (data.recipients?.length ?? 0) > 0
return (
@@ -121,7 +120,8 @@ export function AdminRequestDetailPage() {
/>
{notice &&
{notice.message}}
- {data.type === 'LLM_API_KEY' && data.status === 'APPROVED' && (
+ {/* A request for several people made one key per person; the table below links each. */}
+ {data.type === 'LLM_API_KEY' && data.status === 'APPROVED' && !bulk && (
)}
@@ -140,6 +140,20 @@ export function AdminRequestDetailPage() {
+
+ recipient.resourceId == null
+ ? null
+ : data.type === 'VM'
+ ? adminPaths.vmDetail(recipient.resourceId, activeOrgId)
+ : data.type === 'LLM_API_KEY'
+ ? adminPaths.llmKeyDetail(recipient.resourceId, activeOrgId)
+ : null
+ }
+ />
+
{data.status === 'SUBMITTED' && canDecide && (
('SUBMITTED')
const [type, setType] = useState(undefined)
const [page, setPage] = useState(0)
@@ -60,11 +64,16 @@ export function AdminRequestsPage() {
})
return (
-
-
승인 대기
-
- 제출된 리소스 신청을 검토하고 승인 또는 반려합니다.
-
+
+
+
승인 대기
+
+ 제출된 리소스 신청을 검토하고 승인 또는 반려합니다.
+
+
+ {approvesAnywhere(user) && (
+
새 신청
+ )}
diff --git a/src/pages/BulkRequests.test.tsx b/src/pages/BulkRequests.test.tsx
new file mode 100644
index 00000000..d6140a5d
--- /dev/null
+++ b/src/pages/BulkRequests.test.tsx
@@ -0,0 +1,418 @@
+import { screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, test } from 'vitest'
+import { http, HttpResponse } from 'msw'
+import type { components } from '../api/schema'
+import { adminRequestStore, retriedRecipients } from '../test/msw/handlers/admin'
+import {
+ orgAdminUser,
+ orgViewerUser,
+ refreshSuccessHandler,
+ regularUser,
+} from '../test/msw/handlers/auth'
+import { createdRequestBodies, requestStore } from '../test/msw/handlers/requests'
+import { server } from '../test/msw/server'
+import { renderApp } from '../test/render'
+import { uuid } from '../test/msw/ids'
+
+type User = ReturnType
+type RequestDetail = components['schemas']['RequestDetailResponse']
+type Recipient = components['schemas']['RequestRecipientResponse']
+
+async function fillResourceStep(user: User) {
+ await user.type(await screen.findByLabelText('이름'), '실습 서버')
+ await user.click(screen.getByRole('radio', { name: 'Ubuntu' }))
+ await user.click(screen.getByRole('radio', { name: /컴퓨팅 최적화/ }))
+ await user.click(screen.getByRole('button', { name: '다음' }))
+}
+
+function recipientsFieldset() {
+ return screen.getByRole('group', { name: /대상자/ })
+}
+
+describe('bulk request wizard for a workspace owner', () => {
+ test('lists members and pending invitations and sends the chosen ones', async () => {
+ const user = userEvent.setup()
+ server.use(refreshSuccessHandler('access-user'))
+ renderApp('/console/requests/new?kind=VM')
+
+ await fillResourceStep(user)
+ await user.click(await screen.findByRole('radio', { name: '캡스톤 3조' }))
+ await user.click(screen.getByRole('radio', { name: '정보컴퓨터공학부 실습지원센터' }))
+ await user.type(screen.getByLabelText('사용 목적'), '실습 수업 서버')
+ await user.click(screen.getByRole('radio', { name: /이번 학기/ }))
+
+ const group = await waitFor(() => recipientsFieldset())
+ expect(within(group).getByText('고르지 않으면 본인에게 신청합니다.')).toBeInTheDocument()
+ await user.click(await within(group).findByRole('checkbox', { name: /김철수/ }))
+ await user.click(await within(group).findByRole('checkbox', { name: /jiwoo\.han@pusan\.ac\.kr/ }))
+ await user.click(screen.getByRole('button', { name: '다음' }))
+
+ expect(
+ await screen.findByText('2명 (김철수, jiwoo.han@pusan.ac.kr)'),
+ ).toBeInTheDocument()
+ // Each VM gets a generated name, so the host name is neither asked nor listed.
+ expect(screen.queryByText('호스트 이름')).not.toBeInTheDocument()
+ await user.click(screen.getAllByRole('button', { name: '수정' })[0])
+ await screen.findByLabelText('이름')
+ expect(screen.queryByLabelText('호스트 이름')).not.toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: '다음' }))
+ await user.click(await screen.findByRole('button', { name: '다음' }))
+
+ await user.click(await screen.findByRole('button', { name: '신청 제출' }))
+ expect(await screen.findByText('신청이 접수되었습니다')).toBeInTheDocument()
+ const body = createdRequestBodies.at(-1)!
+ expect(body.recipients).toEqual([{ userId: uuid(57) }, { invitationId: uuid(3101) }])
+ expect(body.vm?.desiredSlug).toBeNull()
+ expect(body.approval).toBeUndefined()
+ })
+
+ test('a rejected recipient returns to the request step on the recipients field', async () => {
+ const user = userEvent.setup()
+ server.use(
+ refreshSuccessHandler('access-user'),
+ http.post('*/api/v1/requests', () =>
+ HttpResponse.json(
+ {
+ type: 'about:blank',
+ title: '입력값을 확인해 주세요',
+ status: 422,
+ detail: '입력값을 확인해 주세요.',
+ code: 'VALIDATION_FAILED',
+ errors: [{ field: 'recipients[0]', message: '이 워크스페이스의 구성원이 아닙니다.' }],
+ },
+ { status: 422, headers: { 'Content-Type': 'application/problem+json' } },
+ ),
+ ),
+ )
+ renderApp('/console/requests/new?kind=VM')
+
+ await fillResourceStep(user)
+ await user.click(await screen.findByRole('radio', { name: '캡스톤 3조' }))
+ await user.click(screen.getByRole('radio', { name: '정보컴퓨터공학부 실습지원센터' }))
+ await user.type(screen.getByLabelText('사용 목적'), '실습 수업 서버')
+ await user.click(screen.getByRole('radio', { name: /이번 학기/ }))
+ await user.click(await within(await waitFor(() => recipientsFieldset())).findByRole('checkbox', { name: /김철수/ }))
+ await user.click(screen.getByRole('button', { name: '다음' }))
+ await user.click(await screen.findByRole('button', { name: '신청 제출' }))
+
+ const group = await waitFor(() => recipientsFieldset())
+ expect(within(group).getByText('이 워크스페이스의 구성원이 아닙니다.')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '다음' })).toBeInTheDocument()
+ })
+
+ test('a request with no one chosen carries no recipients', async () => {
+ const user = userEvent.setup()
+ server.use(refreshSuccessHandler('access-user'))
+ renderApp('/console/requests/new?kind=VM')
+
+ await fillResourceStep(user)
+ await user.click(await screen.findByRole('radio', { name: '캡스톤 3조' }))
+ await user.click(screen.getByRole('radio', { name: '정보컴퓨터공학부 실습지원센터' }))
+ await user.type(screen.getByLabelText('사용 목적'), '실습 수업 서버')
+ await user.click(screen.getByRole('radio', { name: /이번 학기/ }))
+ await user.click(screen.getByRole('button', { name: '다음' }))
+ await user.click(await screen.findByRole('button', { name: '신청 제출' }))
+
+ await screen.findByText('신청이 접수되었습니다')
+ expect(createdRequestBodies.at(-1)).not.toHaveProperty('recipients')
+ })
+
+ test('a plain member who approves nowhere is not offered recipients', async () => {
+ const user = userEvent.setup()
+ server.use(refreshSuccessHandler('access-user'))
+ renderApp('/console/requests/new?kind=VM')
+
+ await fillResourceStep(user)
+ await user.click(await screen.findByRole('radio', { name: '데이터베이스 실습' }))
+ await user.click(screen.getByRole('radio', { name: '정보컴퓨터공학부 실습지원센터' }))
+ expect(screen.queryByRole('group', { name: /대상자/ })).not.toBeInTheDocument()
+ })
+})
+
+describe('bulk request from the administration area', () => {
+ test('the requests page offers the entry to approvers only', async () => {
+ server.use(refreshSuccessHandler('access-org-admin', orgAdminUser))
+ const first = renderApp('/admin/requests')
+ expect(await screen.findByRole('link', { name: '새 신청' })).toHaveAttribute(
+ 'href',
+ expect.stringContaining('/admin/requests/new'),
+ )
+ first.unmount()
+
+ server.use(refreshSuccessHandler('access-org-viewer', orgViewerUser))
+ renderApp('/admin/requests')
+ await screen.findByRole('heading', { name: '승인 대기' })
+ expect(screen.queryByRole('link', { name: '새 신청' })).not.toBeInTheDocument()
+ })
+
+ test('the picker offers only the kinds that can be filed for several people', async () => {
+ server.use(refreshSuccessHandler('access-org-admin', orgAdminUser))
+ renderApp('/admin/requests/new')
+
+ expect(await screen.findByRole('radio', { name: /가상머신/ })).toBeInTheDocument()
+ expect(screen.getByRole('radio', { name: /LLM API 키/ })).toBeInTheDocument()
+ expect(screen.queryByRole('radio', { name: /도메인/ })).not.toBeInTheDocument()
+ })
+
+ test('an approver can also name people who were invited but have not joined', async () => {
+ const user = userEvent.setup()
+ server.use(
+ refreshSuccessHandler('access-org-admin', orgAdminUser),
+ http.get('*/api/v1/admin/workspaces/:workspaceId/invitations', () =>
+ HttpResponse.json([
+ {
+ id: uuid(77),
+ email: 'newcomer@pusan.ac.kr',
+ role: 'MEMBER',
+ invitedAt: '2026-09-27T00:00:00Z',
+ invitedBy: { id: uuid(1), name: '초대한 사람' },
+ },
+ ]),
+ ),
+ )
+ renderApp(`/admin/requests/new?kind=VM&org=${uuid(1)}`)
+
+ await fillResourceStep(user)
+ await user.click(await screen.findByRole('radio', { name: '정보컴퓨터공학부 실습지원센터' }))
+ await user.click(await screen.findByRole('radio', { name: '캡스톤 3조' }))
+
+ const group = await waitFor(() => recipientsFieldset())
+ expect(await within(group).findByRole('checkbox', { name: /newcomer@pusan.ac.kr/ })).toBeInTheDocument()
+ })
+
+ test('submits for chosen members and approves in the same step', async () => {
+ const user = userEvent.setup()
+ server.use(refreshSuccessHandler('access-org-admin', orgAdminUser))
+ renderApp(`/admin/requests/new?kind=VM&org=${uuid(1)}`)
+
+ await fillResourceStep(user)
+ await user.click(await screen.findByRole('radio', { name: '정보컴퓨터공학부 실습지원센터' }))
+ // Only organisations this account decides for are offered.
+ expect(screen.queryByRole('radio', { name: '테스트 기관' })).not.toBeInTheDocument()
+ await user.click(await screen.findByRole('radio', { name: '캡스톤 3조' }))
+ await user.type(screen.getByLabelText('사용 목적'), '실습 수업 서버')
+ await user.click(screen.getByRole('radio', { name: /이번 학기/ }))
+
+ const group = await waitFor(() => recipientsFieldset())
+ expect(await within(group).findByRole('checkbox', { name: /홍길동/ })).toBeInTheDocument()
+ // A withdrawn account can receive nothing.
+ expect(within(group).queryByRole('checkbox', { name: /박탈퇴/ })).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '다음' }))
+ expect(await screen.findByText('대상자를 한 명 이상 선택해 주세요.')).toBeInTheDocument()
+
+ await user.click(within(recipientsFieldset()).getByRole('checkbox', { name: /홍길동/ }))
+ await user.click(screen.getByRole('button', { name: '다음' }))
+
+ expect(await screen.findByRole('heading', { name: '승인 내용' })).toBeInTheDocument()
+ expect(await screen.findByLabelText('vCPU')).toHaveValue(2)
+ expect(screen.queryByLabelText('호스트 이름 확정')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: '제출하고 승인' }))
+ const dialog = await screen.findByRole('dialog', { name: '신청 승인' })
+ await user.click(within(dialog).getByRole('button', { name: '제출하고 승인' }))
+
+ expect(await screen.findByText('신청을 승인했습니다')).toBeInTheDocument()
+ const body = createdRequestBodies.at(-1)!
+ expect(body.recipients).toEqual([{ userId: uuid(42) }])
+ expect(body.approval?.vm?.grantedVcpu).toBe(2)
+ expect(body.approval?.vm?.grantedSlug).toBeNull()
+ expect(body.approval?.grantedStartDate).toBeTruthy()
+ })
+
+ test('an LLM key request reaches the same approval form', async () => {
+ const user = userEvent.setup()
+ server.use(refreshSuccessHandler('access-org-admin', orgAdminUser))
+ renderApp(`/admin/requests/new?kind=LLM_API_KEY&org=${uuid(1)}`)
+
+ await user.type(await screen.findByLabelText('이름'), '실습 키')
+ await user.click(screen.getByRole('checkbox', { name: /Pickle LLM/ }))
+ await user.click(screen.getByRole('button', { name: '다음' }))
+ await user.click(await screen.findByRole('radio', { name: '정보컴퓨터공학부 실습지원센터' }))
+ await user.click(await screen.findByRole('radio', { name: '캡스톤 3조' }))
+ await user.type(screen.getByLabelText('사용 목적'), '실습 수업')
+ await user.click(screen.getByRole('radio', { name: /이번 학기/ }))
+ await user.click(await within(await waitFor(() => recipientsFieldset())).findByRole('checkbox', { name: /전체 선택/ }))
+ await user.click(screen.getByRole('button', { name: '다음' }))
+
+ expect(await screen.findByRole('heading', { name: '승인 내용' })).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: '제출하고 승인' }))
+ const dialog = await screen.findByRole('dialog', { name: '신청 승인' })
+ await user.click(within(dialog).getByRole('button', { name: '제출하고 승인' }))
+
+ expect(await screen.findByText('신청을 승인했습니다')).toBeInTheDocument()
+ const body = createdRequestBodies.at(-1)!
+ expect(body.type).toBe('LLM_API_KEY')
+ expect(body.recipients).toEqual([{ userId: uuid(42) }])
+ expect(body.approval).toBeTruthy()
+ })
+
+ test('the credit limit is weighed once per chosen recipient', async () => {
+ const user = userEvent.setup()
+ server.use(refreshSuccessHandler('access-org-admin', orgAdminUser))
+ renderApp(`/admin/requests/new?kind=LLM_API_KEY&org=${uuid(1)}`)
+
+ await user.type(await screen.findByLabelText('이름'), '실습 키')
+ await user.click(screen.getByRole('checkbox', { name: /Pickle LLM/ }))
+ await user.click(screen.getByRole('button', { name: '다음' }))
+ await user.click(await screen.findByRole('radio', { name: '정보컴퓨터공학부 실습지원센터' }))
+ await user.click(await screen.findByRole('radio', { name: '캡스톤 3조' }))
+ await user.type(screen.getByLabelText('사용 목적'), '실습 수업')
+ await user.click(screen.getByRole('radio', { name: /이번 학기/ }))
+ const all = await within(await waitFor(() => recipientsFieldset())).findByRole('checkbox', {
+ name: /전체 선택/,
+ })
+ const count = /\((\d+)명\)/.exec(all.closest('label')!.textContent ?? '')![1]
+ await user.click(all)
+ await user.click(screen.getByRole('button', { name: '다음' }))
+
+ // Large enough to exceed the balance, so the warning names the multiplication.
+ await user.type(await screen.findByLabelText('부여 금액 한도 (USD)'), '80')
+ await user.selectOptions(screen.getByLabelText('OpenRouter 사업 계정'), uuid(410))
+ expect(await screen.findByText(new RegExp(`× ${count}명`))).toBeInTheDocument()
+ })
+})
+
+function recipient(overrides: Partial & Pick): Recipient {
+ return { userId: null, name: null, invitee: null, resourceId: null, reason: null, ...overrides }
+}
+
+function bulkRequest(base: RequestDetail, overrides: Partial): RequestDetail {
+ return {
+ ...base,
+ status: 'APPROVED',
+ review: {
+ reviewerId: orgAdminUser.id,
+ reviewerName: orgAdminUser.name,
+ decision: 'APPROVE',
+ comment: null,
+ grantedStartDate: '2026-09-01',
+ grantedEndDate: '2026-12-20',
+ decidedAt: '2026-09-01T10:00:00+09:00',
+ },
+ ...overrides,
+ }
+}
+
+const RECIPIENTS: Recipient[] = [
+ recipient({ id: uuid(9001), userId: uuid(57), name: '김철수', status: 'CREATED', resourceId: uuid(8801) }),
+ recipient({ id: uuid(9002), userId: uuid(58), name: '이영희', status: 'FAILED', reason: '기관 용량이 부족합니다.' }),
+ recipient({ id: uuid(9003), invitee: 'jiwoo.han@pusan.ac.kr', status: 'PENDING_JOIN' }),
+ recipient({
+ id: uuid(9004),
+ userId: uuid(59),
+ name: '박민수',
+ status: 'SOMETHING_NEW' as Recipient['status'],
+ }),
+]
+
+describe('recipients on the administrator request detail', () => {
+ test('shows each person, links the created resource and retries a failure', async () => {
+ const user = userEvent.setup()
+ adminRequestStore.push(
+ bulkRequest(adminRequestStore[0], { id: uuid(299), recipients: RECIPIENTS.map((r) => ({ ...r })) }),
+ )
+ server.use(refreshSuccessHandler('access-org-admin', orgAdminUser))
+ renderApp(`/admin/requests/${uuid(299)}`)
+
+ const table = await screen.findByRole('table', { name: '대상자' })
+ const rows = within(table).getAllByRole('row')
+ expect(within(rows[1]).getByText('생성됨')).toBeInTheDocument()
+ expect(within(rows[1]).getByRole('link', { name: '상세 보기' })).toHaveAttribute(
+ 'href',
+ `/admin/vms/${uuid(8801)}?org=${uuid(1)}`,
+ )
+ expect(within(rows[2]).getByText('실패')).toBeInTheDocument()
+ expect(within(rows[2]).getByText('기관 용량이 부족합니다.')).toBeInTheDocument()
+ expect(within(rows[3]).getByText('jiwoo.han@pusan.ac.kr')).toBeInTheDocument()
+ expect(within(rows[3]).getByText('가입 대기')).toBeInTheDocument()
+ // A value this build does not know shows as itself.
+ expect(within(rows[4]).getByText('SOMETHING_NEW')).toBeInTheDocument()
+
+ const retry = within(table).getAllByRole('button', { name: '다시 시도' })
+ expect(retry).toHaveLength(1)
+ await user.click(retry[0])
+ await waitFor(() =>
+ expect(retriedRecipients).toEqual([{ requestId: uuid(299), recipientId: uuid(9002) }]),
+ )
+ expect(await within(table).findByText('생성 대기')).toBeInTheDocument()
+ })
+
+ test('a viewer sees the table without the retry action', async () => {
+ adminRequestStore.push(
+ bulkRequest(adminRequestStore[0], { id: uuid(299), recipients: RECIPIENTS.map((r) => ({ ...r })) }),
+ )
+ server.use(refreshSuccessHandler('access-org-viewer', orgViewerUser))
+ renderApp(`/admin/requests/${uuid(299)}`)
+
+ const table = await screen.findByRole('table', { name: '대상자' })
+ expect(within(table).getByText('실패')).toBeInTheDocument()
+ expect(within(table).queryByRole('button', { name: '다시 시도' })).not.toBeInTheDocument()
+ })
+
+ test('approving a bulk VM request settles no single host name', async () => {
+ adminRequestStore.push({
+ ...adminRequestStore[0],
+ id: uuid(298),
+ recipients: [recipient({ id: uuid(9101), userId: uuid(57), name: '김철수', status: 'QUEUED' })],
+ })
+ server.use(refreshSuccessHandler('access-org-admin', orgAdminUser))
+ renderApp(`/admin/requests/${uuid(298)}`)
+
+ expect(await screen.findByLabelText('vCPU')).toBeInTheDocument()
+ expect(screen.queryByLabelText('호스트 이름 확정')).not.toBeInTheDocument()
+ })
+
+ test('approving a bulk VM request says each VM is created in turn', async () => {
+ const user = userEvent.setup()
+ adminRequestStore.push({
+ ...adminRequestStore[0],
+ id: uuid(297),
+ recipients: [recipient({ id: uuid(9102), userId: uuid(57), name: '김철수', status: 'QUEUED' })],
+ })
+ server.use(refreshSuccessHandler('access-org-admin', orgAdminUser))
+ renderApp(`/admin/requests/${uuid(297)}`)
+
+ await screen.findByLabelText('vCPU')
+ await user.click(screen.getByRole('button', { name: '승인하기' }))
+ const dialog = await screen.findByRole('dialog', { name: '신청 승인' })
+ await user.click(within(dialog).getByRole('button', { name: '승인 확정' }))
+ expect(
+ await screen.findByText(
+ '신청을 승인했습니다. 대상자별 VM이 차례로 생성되고, 가입하지 않은 대상자의 VM은 가입할 때 생성됩니다.',
+ ),
+ ).toBeInTheDocument()
+ })
+})
+
+describe('recipients on the applicant request detail', () => {
+ test('links only the viewer own resource and offers no retry', async () => {
+ requestStore.push(
+ bulkRequest(requestStore[0], {
+ id: uuid(399),
+ recipients: [
+ recipient({ id: uuid(9201), userId: regularUser.id, name: regularUser.name, status: 'CREATED', resourceId: uuid(8802) }),
+ recipient({ id: uuid(9202), userId: uuid(57), name: '김철수', status: 'CREATED', resourceId: uuid(8803) }),
+ recipient({ id: uuid(9203), userId: uuid(58), name: '이영희', status: 'FAILED', reason: '기관 용량이 부족합니다.' }),
+ ],
+ }),
+ )
+ server.use(refreshSuccessHandler('access-user'))
+ renderApp(`/console/requests/${uuid(399)}`)
+
+ const table = await screen.findByRole('table', { name: '대상자' })
+ const links = within(table).getAllByRole('link', { name: '상세 보기' })
+ expect(links).toHaveLength(1)
+ expect(links[0]).toHaveAttribute('href', `/console/vms/${uuid(8802)}`)
+ expect(within(table).queryByRole('button', { name: '다시 시도' })).not.toBeInTheDocument()
+ })
+
+ test('an ordinary request shows no recipients table', async () => {
+ server.use(refreshSuccessHandler('access-user'))
+ renderApp(`/console/requests/${uuid(101)}`)
+
+ await screen.findByRole('heading', { name: '신청 상세' })
+ expect(screen.queryByRole('table', { name: '대상자' })).not.toBeInTheDocument()
+ })
+})
diff --git a/src/pages/RequestDetailPage.tsx b/src/pages/RequestDetailPage.tsx
index 1bf3670b..34e07b2e 100644
--- a/src/pages/RequestDetailPage.tsx
+++ b/src/pages/RequestDetailPage.tsx
@@ -18,6 +18,11 @@ import {
} from '../components/ui'
import { requestKindView } from '../components/request-kind'
import { Field } from '../components/request-kind/Field'
+import { RequestRecipientsCard } from '../components/request-kind/RecipientsCard'
+import { recipientPollInterval } from '../components/request-kind/recipients'
+import { useAuth } from '../auth/auth-context'
+import { approvesForOrg } from '../auth/permissions'
+import { consolePaths } from '../lib/paths'
import { formatDateTime } from '../lib/format'
import { INVALID_ID_MESSAGE, isUuid } from '../lib/validation'
@@ -26,11 +31,13 @@ export function RequestDetailPage() {
const requestId = params.requestId ?? ''
const idValid = isUuid(requestId)
const [cancelError, setCancelError] = useState(null)
+ const { user } = useAuth()
const request = useQuery({
queryKey: ['requests', requestId],
queryFn: () => fetchRequest(requestId),
// 형식부터 틀린 주소는 서버에 물어볼 것이 없다.
enabled: idValid,
+ refetchInterval: (query) => recipientPollInterval(query.state.data),
})
if (!idValid) {
return {INVALID_ID_MESSAGE}
@@ -79,6 +86,21 @@ export function RequestDetailPage() {
{data.review && }
+
+ recipient.resourceId == null || recipient.userId == null || recipient.userId !== user?.id
+ ? null
+ : data.type === 'VM'
+ ? consolePaths.vmDetail(recipient.resourceId)
+ : data.type === 'LLM_API_KEY'
+ ? consolePaths.llmKeyDetail(recipient.resourceId)
+ : null
+ }
+ />
+
신청 내용
diff --git a/src/test/msw/handlers/admin.ts b/src/test/msw/handlers/admin.ts
index 9db1ce39..704acf9a 100644
--- a/src/test/msw/handlers/admin.ts
+++ b/src/test/msw/handlers/admin.ts
@@ -26,6 +26,7 @@ export function submittedAdminRequest(n: number): RequestDetail {
return {
id: uuid(n),
workspaceId: uuid(12),
+ recipients: [],
workspaceName: '캡스톤 3조',
orgId: uuid(1),
orgName: '정보컴퓨터공학부 실습지원센터',
@@ -435,6 +436,7 @@ let nextOrgId = 100
/** Bodies received by decision endpoints, for payload-correctness assertions. */
export let approveBodies: { requestId: string; body: Schemas['ApproveRequestRequest'] }[] = []
export let rejectBodies: { requestId: string; body: { comment: string } }[] = []
+export let retriedRecipients: { requestId: string; recipientId: string }[] = []
export let userPatchBodies: {
userId: string
body: { role?: Schemas['AdminGlobalRole'] }
@@ -445,6 +447,7 @@ export function resetAdminFixtures() {
approvalContexts = initialContexts()
approveBodies = []
rejectBodies = []
+ retriedRecipients = []
userPatchBodies = []
nextOrgId = 100
adminOsImages = initialAdminOsImages()
@@ -739,6 +742,27 @@ export const adminHandlers: RequestHandler[] = [
return HttpResponse.json(found, { status: 200 })
}),
+ http.post('*/api/v1/admin/requests/:requestId/recipients/:recipientId/retry', ({ params }) => {
+ const requestId = String(params.requestId)
+ const found = adminRequestStore.find((r) => r.id === requestId)
+ const recipient = found?.recipients?.find((r) => r.id === String(params.recipientId))
+ if (!found || !recipient) return notFound()
+ // Like the server: only a failed recipient goes back into the queue.
+ if (recipient.status !== 'FAILED') {
+ return problemResponse({
+ type: 'about:blank',
+ title: '다시 시도할 수 없습니다',
+ status: 409,
+ detail: '실패한 대상자만 다시 시도할 수 있습니다.',
+ code: 'REQUEST_RECIPIENT_NOT_RETRYABLE',
+ })
+ }
+ retriedRecipients.push({ requestId, recipientId: recipient.id })
+ recipient.status = 'QUEUED'
+ recipient.reason = null
+ return HttpResponse.json(found, { status: 200 })
+ }),
+
http.post('*/api/v1/admin/requests/:requestId/reject', async ({ params, request }) => {
const requestId = String(params.requestId)
const found = adminRequestStore.find((r) => r.id === requestId)
diff --git a/src/test/msw/handlers/announcements.ts b/src/test/msw/handlers/announcements.ts
index cc927e14..ce8e46f7 100644
--- a/src/test/msw/handlers/announcements.ts
+++ b/src/test/msw/handlers/announcements.ts
@@ -119,6 +119,8 @@ const workspaceDetails: Record
}
export const announcementHandlers: RequestHandler[] = [
+ // No pending invitations unless a test overrides this.
+ http.get('*/api/v1/admin/workspaces/:workspaceId/invitations', () => HttpResponse.json([])),
http.get('*/api/v1/admin/workspaces/:workspaceId', ({ params }) => {
const detail = workspaceDetails[String(params.workspaceId)]
if (!detail) {
@@ -137,6 +139,10 @@ export const announcementHandlers: RequestHandler[] = [
const profile = profileOf(request)
if (!profile) return problemResponse(unauthorizedProblem)
const url = new URL(request.url)
+ // all=true: every workspace to any admin role (the admin request screen).
+ if (url.searchParams.get('all') === 'true') {
+ return HttpResponse.json(Object.values(workspaceOptionsByOrg).flat(), { status: 200 })
+ }
const orgId = url.searchParams.get('orgId')
// 계약 v0.46.0: 기관 계층은 역할을 보유한 기관 안만 본다. 보유하지 않은
// 기관이나 없는 기관을 지정하면 404 (존재 비공개).
diff --git a/src/test/msw/handlers/requests.ts b/src/test/msw/handlers/requests.ts
index 14035069..1030ebd1 100644
--- a/src/test/msw/handlers/requests.ts
+++ b/src/test/msw/handlers/requests.ts
@@ -3,6 +3,7 @@ import type { components } from '../../../api/schema'
import { ACCESS_TOKENS, problemResponse, regularUser, regularUserB } from './auth'
import { flavorStore, osImages } from './reference'
import { uuid } from '../ids'
+import { workspaceMembersOf } from './workspaces'
type Schemas = components['schemas']
type RequestDetail = Schemas['RequestDetailResponse']
@@ -18,6 +19,7 @@ function baseRequest(): Omit<
'id' | 'purpose' | 'status' | 'review' | 'createdAt' | 'updatedAt'
> {
return {
+ recipients: [],
workspaceId: uuid(12),
workspaceName: '캡스톤 3조',
orgId: uuid(1),
@@ -267,11 +269,32 @@ export const requestHandlers: RequestHandler[] = [
http.post('*/api/v1/requests', async ({ request }) => {
const body = (await request.json()) as Schemas['CreateRequestRequest']
createdRequestBodies.push(body)
+ const { recipients: recipientBodies, approval, ...fields } = body
+ const actor = ACCESS_TOKENS[request.headers.get('Authorization')?.replace('Bearer ', '') ?? '']
// 접수와 동시에 승인되는 것은 지금 도메인뿐이고, 그것도 루트의 정책이 정한다.
const autoApproved = body.type === 'DOMAIN' && domainRootPolicy.autoApprove
+ // Like the server: named recipients become rows, a member waits in the
+ // creation queue and an invitation waits for its person to join.
+ const recipients: Schemas['RequestRecipientResponse'][] = (recipientBodies ?? []).map(
+ (entry, index) => {
+ const member = entry.userId
+ ? workspaceMembersOf(body.workspaceId).find((m) => m.userId === entry.userId)
+ : undefined
+ return {
+ id: uuid(5000 + nextRequestId * 10 + index),
+ userId: entry.userId ?? null,
+ name: member?.name ?? null,
+ invitee: entry.invitationId ? 'invitee@pusan.ac.kr' : null,
+ status: entry.userId ? 'QUEUED' : 'PENDING_JOIN',
+ resourceId: null,
+ reason: null,
+ }
+ },
+ )
const created: RequestDetail = {
...baseRequest(),
- ...body,
+ ...fields,
+ recipients,
id: uuid(nextRequestId++),
workspaceName: '캡스톤 3조',
orgName: '정보컴퓨터공학부 실습지원센터',
@@ -327,10 +350,20 @@ export const requestHandlers: RequestHandler[] = [
// 신청서에서 도메인 축이 빠졌다 — 새 신청의 이력 필드는 항상 비어 있다.
granted: null,
},
- status: autoApproved ? 'APPROVED' : 'SUBMITTED',
+ status: autoApproved || approval ? 'APPROVED' : 'SUBMITTED',
// 자동 승인도 검토 행을 남긴다. 결재자가 없다는 것이 기록이고, 이름은
// 「탈퇴 회원」이 아니라 「자동 승인」이다.
- review: autoApproved
+ review: approval
+ ? {
+ decision: 'APPROVE',
+ comment: approval.comment ?? null,
+ grantedStartDate: approval.grantedStartDate ?? null,
+ grantedEndDate: approval.grantedEndDate ?? null,
+ decidedAt: '2026-07-08T15:00:00+09:00',
+ reviewerId: actor?.id ?? null,
+ reviewerName: actor?.name ?? '',
+ }
+ : autoApproved
? {
decision: 'APPROVE',
comment: null,