Skip to content

Commit e04336c

Browse files
charlieparkdavid-crespobenjaminleonard
authored
Add SCIM UI (#2926)
* Add SCIM tab to UI * Add tests * Tweak truncation * Use 90 day default for expiration time in mock data * revert UI copy change * TODOs update * Remove Delete All from UI and set handler to NotImplemented * npm i * update npm and re-run npm i * Remove todo list * Update import and className ordering * Update path snapshots * tweak height of token pseudo input; order tokens by created_at * Remove EXPIRES column and content from confirmation modal * Simplify mock handler * Revert "Remove EXPIRES column and content from confirmation modal" This reverts commit 47e43af. * Set mock token data to have null expiration datetime * Hide 'Learn about SCIM Auth' text until we have documentation up that we can link to * Remove unnecessary test * For desktops, give a min-width to the Expires column so it doesn't feel so squeezed * SCIM UI tweaks * Fix funky modal (unrelated to SCIM) * Message spacing tweak * Unify input label with others * tweak copy, add saml_scim option in silo create * put oxide-scim- back in. see oxidecomputer/omicron#9301 * fix e2e failure and bug around admin group name * take out the docs link so we can merge --------- Co-authored-by: David Crespo <[email protected]> Co-authored-by: Benjamin Leonard <[email protected]>
1 parent 3947c8c commit e04336c

File tree

17 files changed

+501
-27
lines changed

17 files changed

+501
-27
lines changed

app/forms/anti-affinity-group-member-add.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }:
6363
<Modal isOpen onDismiss={onDismiss} title="Add instance to group">
6464
<Modal.Body>
6565
<Modal.Section>
66-
<p className="text-sm text-gray-500">
66+
<p>
6767
Select an instance to add to the anti-affinity group{' '}
68-
<HL>{antiAffinityGroup}</HL>. Only stopped instances can be added to the group.
68+
<HL>{antiAffinityGroup}</HL>.
6969
</p>
7070
<form id={formId} onSubmit={onSubmit}>
7171
<ComboboxField

app/forms/silo-create.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { useEffect } from 'react'
99
import { useForm } from 'react-hook-form'
1010
import { useNavigate } from 'react-router'
11+
import { match } from 'ts-pattern'
1112

1213
import { useApiMutation, useApiQueryClient, type SiloCreate } from '@oxide/api'
1314

@@ -146,15 +147,20 @@ export default function CreateSiloSideModalForm() {
146147
column
147148
control={form.control}
148149
items={[
149-
{ value: 'saml_jit', label: 'SAML' },
150+
{ value: 'saml_jit', label: 'SAML + JIT' },
151+
{ value: 'saml_scim', label: 'SAML + SCIM' },
150152
{ value: 'local_only', label: 'Local only' },
151153
]}
152154
/>
153-
{identityMode === 'saml_jit' && (
155+
{match(identityMode)
156+
.with('saml_jit', () => true)
157+
.with('saml_scim', () => true)
158+
.with('local_only', () => false)
159+
.exhaustive() && (
154160
<TextField
155161
name="adminGroupName"
156162
label="Admin group name"
157-
description="This group will be created and granted the Silo Admin role."
163+
description="This group will be created and granted the admin role on the silo."
158164
control={form.control}
159165
/>
160166
)}

app/pages/system/silos/SiloPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export default function SiloPage() {
6161
<Tab to={pb.siloIpPools(siloSelector)}>IP Pools</Tab>
6262
<Tab to={pb.siloQuotas(siloSelector)}>Quotas</Tab>
6363
<Tab to={pb.siloFleetRoles(siloSelector)}>Fleet roles</Tab>
64+
<Tab to={pb.siloScim(siloSelector)}>SCIM</Tab>
6465
</RouteTabs>
6566
</>
6667
)
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
10+
import { useCallback, useMemo, useState } from 'react'
11+
import { type LoaderFunctionArgs } from 'react-router'
12+
13+
import { AccessToken24Icon } from '@oxide/design-system/icons/react'
14+
import { Badge } from '@oxide/design-system/ui'
15+
16+
import {
17+
apiQueryClient,
18+
useApiMutation,
19+
usePrefetchedApiQuery,
20+
type ScimClientBearerToken,
21+
} from '~/api'
22+
import { getSiloSelector, useSiloSelector } from '~/hooks/use-params'
23+
import { confirmDelete } from '~/stores/confirm-delete'
24+
import { addToast } from '~/stores/toast'
25+
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
26+
import { Columns } from '~/table/columns/common'
27+
import { Table } from '~/table/Table'
28+
import { CardBlock } from '~/ui/lib/CardBlock'
29+
import { CopyToClipboard } from '~/ui/lib/CopyToClipboard'
30+
import { CreateButton } from '~/ui/lib/CreateButton'
31+
import { DateTime } from '~/ui/lib/DateTime'
32+
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
33+
import { Message } from '~/ui/lib/Message'
34+
import { Modal } from '~/ui/lib/Modal'
35+
import { TableEmptyBox } from '~/ui/lib/Table'
36+
import { Truncate } from '~/ui/lib/Truncate'
37+
38+
const colHelper = createColumnHelper<ScimClientBearerToken>()
39+
40+
const EmptyState = () => (
41+
<TableEmptyBox border={false}>
42+
<EmptyMessage
43+
icon={<AccessToken24Icon />}
44+
title="No SCIM tokens"
45+
body="Create a token to see it here"
46+
/>
47+
</TableEmptyBox>
48+
)
49+
50+
export async function clientLoader({ params }: LoaderFunctionArgs) {
51+
const { silo } = getSiloSelector(params)
52+
await apiQueryClient.prefetchQuery('scimTokenList', { query: { silo } })
53+
return null
54+
}
55+
56+
export default function SiloScimTab() {
57+
const siloSelector = useSiloSelector()
58+
const { data } = usePrefetchedApiQuery('scimTokenList', {
59+
query: { silo: siloSelector.silo },
60+
})
61+
62+
// Order tokens by creation date, oldest first
63+
const tokens = useMemo(
64+
() => [...data].sort((a, b) => a.timeCreated.getTime() - b.timeCreated.getTime()),
65+
[data]
66+
)
67+
68+
const [showCreateModal, setShowCreateModal] = useState(false)
69+
const [createdToken, setCreatedToken] = useState<{
70+
id: string
71+
bearerToken: string
72+
timeCreated: Date
73+
timeExpires?: Date | null
74+
} | null>(null)
75+
76+
const deleteToken = useApiMutation('scimTokenDelete', {
77+
onSuccess() {
78+
apiQueryClient.invalidateQueries('scimTokenList')
79+
},
80+
})
81+
82+
const makeActions = useCallback(
83+
(token: ScimClientBearerToken): MenuAction[] => [
84+
{
85+
label: 'Delete',
86+
onActivate: confirmDelete({
87+
doDelete: () =>
88+
deleteToken.mutateAsync({
89+
path: { tokenId: token.id },
90+
query: { silo: siloSelector.silo },
91+
}),
92+
label: token.id,
93+
}),
94+
},
95+
],
96+
[deleteToken, siloSelector.silo]
97+
)
98+
99+
const staticColumns = useMemo(
100+
() => [
101+
colHelper.accessor('id', {
102+
header: 'ID',
103+
cell: (info) => (
104+
<Truncate text={info.getValue()} position="middle" maxLength={18} />
105+
),
106+
}),
107+
colHelper.accessor('timeCreated', Columns.timeCreated),
108+
colHelper.accessor('timeExpires', {
109+
header: 'Expires',
110+
cell: (info) => {
111+
const expires = info.getValue()
112+
return expires ? (
113+
<DateTime date={expires} />
114+
) : (
115+
<Badge color="neutral">Never</Badge>
116+
)
117+
},
118+
meta: { thClassName: 'lg:w-1/4' },
119+
}),
120+
],
121+
[]
122+
)
123+
124+
const columns = useColsWithActions(staticColumns, makeActions, 'Copy token ID')
125+
126+
const table = useReactTable({
127+
data: tokens,
128+
columns,
129+
getCoreRowModel: getCoreRowModel(),
130+
})
131+
// const { href, linkText } = docLinks.scim
132+
return (
133+
<>
134+
<CardBlock>
135+
<CardBlock.Header
136+
title="SCIM Tokens"
137+
titleId="scim-tokens-label"
138+
description="Tokens for authenticating requests to SCIM endpoints"
139+
>
140+
<CreateButton onClick={() => setShowCreateModal(true)}>Create token</CreateButton>
141+
</CardBlock.Header>
142+
<CardBlock.Body>
143+
{tokens.length === 0 ? (
144+
<EmptyState />
145+
) : (
146+
<Table
147+
aria-labelledby="scim-tokens-label"
148+
table={table}
149+
className="table-inline"
150+
/>
151+
)}
152+
</CardBlock.Body>
153+
{/* TODO: put this back!
154+
<CardBlock.Footer>
155+
<LearnMore href={links.scimDocs} text="SCIM" />
156+
</CardBlock.Footer> */}
157+
</CardBlock>
158+
159+
{showCreateModal && (
160+
<CreateTokenModal
161+
siloSelector={siloSelector}
162+
onDismiss={() => setShowCreateModal(false)}
163+
onSuccess={(token) => {
164+
setShowCreateModal(false)
165+
setCreatedToken(token)
166+
}}
167+
/>
168+
)}
169+
170+
{createdToken && (
171+
<TokenCreatedModal token={createdToken} onDismiss={() => setCreatedToken(null)} />
172+
)}
173+
</>
174+
)
175+
}
176+
177+
function CreateTokenModal({
178+
siloSelector,
179+
onDismiss,
180+
onSuccess,
181+
}: {
182+
siloSelector: { silo: string }
183+
onDismiss: () => void
184+
onSuccess: (token: {
185+
id: string
186+
bearerToken: string
187+
timeCreated: Date
188+
timeExpires?: Date | null
189+
}) => void
190+
}) {
191+
const createToken = useApiMutation('scimTokenCreate', {
192+
onSuccess(token) {
193+
apiQueryClient.invalidateQueries('scimTokenList')
194+
onSuccess(token)
195+
},
196+
onError(err) {
197+
addToast({ variant: 'error', title: 'Failed to create token', content: err.message })
198+
},
199+
})
200+
201+
return (
202+
<Modal isOpen onDismiss={onDismiss} title="Create SCIM token">
203+
<Modal.Section>
204+
Anyone with this token can manage users and groups in this silo via SCIM. Since
205+
group membership grants roles, this token can be used to give a user admin
206+
privileges. Store it securely and never share it publicly.
207+
</Modal.Section>
208+
209+
<Modal.Footer
210+
onDismiss={onDismiss}
211+
onAction={() => {
212+
createToken.mutate({ query: { silo: siloSelector.silo } })
213+
}}
214+
actionText="Create"
215+
actionLoading={createToken.isPending}
216+
/>
217+
</Modal>
218+
)
219+
}
220+
221+
function TokenCreatedModal({
222+
token,
223+
onDismiss,
224+
}: {
225+
token: {
226+
id: string
227+
bearerToken: string
228+
timeCreated: Date
229+
timeExpires?: Date | null
230+
}
231+
onDismiss: () => void
232+
}) {
233+
return (
234+
<Modal isOpen onDismiss={onDismiss} title="SCIM token created">
235+
<Modal.Section>
236+
<Message
237+
variant="notice"
238+
content=<>
239+
This is the only time you’ll see this token. Copy it now and store it securely.
240+
</>
241+
/>
242+
243+
<div className="mt-4">
244+
<div className="text-sans-md text-raise mb-2">Bearer Token</div>
245+
<div className="text-sans-md text-raise bg-default border-default flex items-stretch rounded border">
246+
<div className="flex-1 overflow-hidden px-3 py-2.75 text-ellipsis">
247+
{token.bearerToken}
248+
</div>
249+
<div className="border-default flex w-8 items-center justify-center border-l">
250+
<CopyToClipboard text={token.bearerToken} />
251+
</div>
252+
</div>
253+
</div>
254+
</Modal.Section>
255+
256+
<Modal.Footer
257+
onDismiss={onDismiss}
258+
actionText="Done"
259+
onAction={onDismiss}
260+
showCancel={false}
261+
/>
262+
</Modal>
263+
)
264+
}

app/routes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ export const routes = createRoutesFromElements(
154154
path="fleet-roles"
155155
lazy={() => import('./pages/system/silos/SiloFleetRolesTab').then(convert)}
156156
/>
157+
<Route
158+
path="scim"
159+
lazy={() => import('./pages/system/silos/SiloScimTab').then(convert)}
160+
/>
157161
</Route>
158162
</Route>
159163
<Route path="issues" element={null} />

app/ui/lib/Message.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,17 @@ const defaultIcon: Record<Variant, ReactElement> = {
3838
}
3939

4040
const color: Record<Variant, string> = {
41-
success: 'bg-accent-secondary',
42-
error: 'bg-error-secondary',
43-
notice: 'bg-notice-secondary',
44-
info: 'bg-info-secondary',
41+
success: 'bg-accent-secondary border-accent/10',
42+
error: 'bg-error-secondary border-destructive/10',
43+
notice: 'bg-notice-secondary border-notice/10',
44+
info: 'bg-info-secondary border-blue-800/10',
4545
}
4646

4747
const textColor: Record<Variant, string> = {
48-
success: 'text-accent *:text-accent',
49-
error: 'text-error *:text-error',
50-
notice: 'text-notice *:text-notice',
51-
info: 'text-info *:text-info',
48+
success: 'text-accent',
49+
error: 'text-error',
50+
notice: 'text-notice',
51+
info: 'text-info',
5252
}
5353

5454
const secondaryTextColor: Record<Variant, string> = {
@@ -77,22 +77,26 @@ export const Message = ({
7777
return (
7878
<div
7979
className={cn(
80-
'elevation-1 relative flex items-start gap-2.5 overflow-hidden rounded-lg p-4',
80+
'elevation-1 relative flex items-start gap-2 overflow-hidden rounded-lg border p-3 pr-5',
8181
color[variant],
8282
textColor[variant],
8383
className
8484
)}
8585
>
8686
{showIcon && (
87-
<div className="mt-[2px] flex [&>svg]:h-3 [&>svg]:w-3">{defaultIcon[variant]}</div>
87+
<div
88+
className={cn(
89+
'mt-[2px] flex [&>svg]:h-3 [&>svg]:w-3',
90+
`[&>svg]:${textColor[variant]}`
91+
)}
92+
>
93+
{defaultIcon[variant]}
94+
</div>
8895
)}
8996
<div className="flex-1">
9097
{title && <div className="text-sans-semi-md">{title}</div>}
9198
<div
92-
className={cn(
93-
'text-sans-md [&>a]:underline',
94-
title ? secondaryTextColor[variant] : textColor[variant]
95-
)}
99+
className={cn('text-sans-md [&>a]:tint-underline', secondaryTextColor[variant])}
96100
>
97101
{content}
98102
</div>

0 commit comments

Comments
 (0)