Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ SPA이고, 데이터는 전부 백엔드 API에서 받아옵니다.
- **신청서**: 리소스 종류를 고르고, 만들 리소스(이름과 OS, 사양)를 정한 뒤, 누구 명의로
왜 언제까지 쓸지를 적습니다. 종류를 아는 자리에서 들어오면 첫 단계는 접힙니다. 사양은
준비된 것을 고르거나 직접 적을 수 있고, 직접 적으면 사유를 받아 관리자가 검토합니다.
접속 이름(SSH 접속명)도 여기서 직접 정할 수 있습니다.
접속 이름(SSH 접속명)도 여기서 직접 정할 수 있습니다. 워크스페이스 소유자와 승인 권한자는
구성원과 대기 중인 초대를 대상자로 골라 가상머신과 LLM API 키를 여러 사람에게 한 번에
신청할 수 있고, 초대받은 사람의 리소스는 가입할 때 만들어집니다. 관리자 콘솔에서는 같은
신청서로 제출과 동시에 승인하고, 신청 상세에서 대상자별 생성 상태를 확인합니다.
- **워크스페이스 스코프**: 사이드바 선택기로 워크스페이스 하나를 고르면 그 워크스페이스가
가진 리소스와 신청을 전부 보고, 접근 권한이 없는 리소스는 이름과 상태, 소유자까지
보입니다. '전체'를 고르면 워크스페이스를 가리지 않고 본인이 접근 권한을 가진 리소스와
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -238,6 +239,7 @@ function App() {
<Route index element={<AdminDashboardPage />} />
<Route path="docs/*" element={guidePage} />
<Route path="requests" element={<AdminRequestsPage />} />
<Route path="requests/new" element={<AdminNewRequestPage />} />
<Route path="requests/:requestId" element={<AdminRequestDetailPage />} />
<Route path="vms" element={<AdminVmsPage />} />
<Route path="vms/:vmId" element={<AdminVmDetailPage />} />
Expand Down
29 changes: 28 additions & 1 deletion src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -239,6 +242,17 @@ export function fetchWorkspaceInvitations(workspaceId: string): Promise<Workspac
})
}

/** Pending invitations as an approver of the workspace's organisation sees them. */
export function fetchAdminWorkspaceInvitations(workspaceId: string): Promise<WorkspaceInvitation[]> {
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<OrgSummary[]> {
return guardNetwork(async () => {
const { data, error } = await api.GET('/orgs')
Expand Down Expand Up @@ -2071,7 +2085,20 @@ export function resendAdminNotification(notificationId: string): Promise<Message
})
}

export function fetchAdminWorkspaces(params: { orgId?: string } = {}): Promise<AdminWorkspaceOption[]> {
/** Moves a failed recipient of a bulk request back into the creation queue. */
export function retryRequestRecipient(requestId: string, recipientId: string): Promise<RequestDetail> {
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<AdminWorkspaceOption[]> {
return guardNetwork(async () => {
const { data, error } = await api.GET('/admin/workspaces', { params: { query: params } })
if (!data) throw toApiError(error, '워크스페이스 목록을 불러오지 못했습니다.')
Expand Down
147 changes: 147 additions & 0 deletions src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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와 함께 보낼 수 없습니다. */
Expand Down Expand Up @@ -7535,6 +7592,8 @@ export interface components {
*/
periodName?: string | null;
purpose: string;
/** @description 여러 사람에게 리소스를 만드는 신청의 대상자. 신청자 본인이 받는 일반 신청은 빈 배열입니다. */
recipients: components["schemas"]["RequestRecipientResponse"][];
/**
* Format: date
* @description 신청한 사용 종료일. 값이 없으면 무기한을 요청한 것입니다.
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -12728,6 +12842,8 @@ export interface operations {
parameters: {
query?: {
orgId?: string;
/** @description true면 기관 필터와 무관하게 삭제되지 않은 모든 워크스페이스를 돌려줍니다. 관리자가 대상자를 골라 신청할 워크스페이스를 고를 때 씁니다. */
all?: boolean;
};
header?: never;
path?: never;
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions src/auth/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
16 changes: 13 additions & 3 deletions src/components/request-kind/KindPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
* 무엇을 신청할지 고르는 화면.
Expand All @@ -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 (
<div className="mx-auto w-full max-w-3xl space-y-6">
Expand All @@ -34,8 +44,8 @@ export function KindPicker() {
<CardRadioGroup
legend="무엇을 신청할까요"
value=""
onChange={(type) => 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,
Expand Down
Loading
Loading