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
4 changes: 2 additions & 2 deletions app/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,8 @@ export const getApiQueryOptionsErrorsAllowed =
queryFn: ({ signal }) =>
api[method](params, { signal })
.then(handleResult(method))
.then((data) => ({ type: 'success' as const, data }))
.catch((data) => ({ type: 'error' as const, data })),
.then((data: Result<A[M]>) => ({ type: 'success' as const, data }))
.catch((data: ApiError) => ({ type: 'error' as const, data })),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug in apiqErrorsAllowed causing the data field to have type any in both arms. There is a better way to do this without casting but I'm going to do it later.

...options,
})

Expand Down
208 changes: 102 additions & 106 deletions app/pages/system/silos/SiloScimTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { useCallback, useMemo, useState } from 'react'
import { type LoaderFunctionArgs } from 'react-router'
import * as R from 'remeda'
import { match } from 'ts-pattern'

import { AccessToken24Icon } from '@oxide/design-system/icons/react'
import { Badge } from '@oxide/design-system/ui'

import {
apiQueryClient,
apiqErrorsAllowed,
queryClient,
useApiMutation,
usePrefetchedApiQuery,
usePrefetchedQuery,
type ScimClientBearerToken,
type ScimClientBearerTokenValue,
} from '~/api'
import { makeCrumb } from '~/hooks/use-crumbs'
import { getSiloSelector, useSiloSelector } from '~/hooks/use-params'
Expand All @@ -36,7 +40,24 @@ import { Modal } from '~/ui/lib/Modal'
import { TableEmptyBox } from '~/ui/lib/Table'
import { Truncate } from '~/ui/lib/Truncate'

export const handle = makeCrumb('SCIM')

const colHelper = createColumnHelper<ScimClientBearerToken>()
const staticColumns = [
colHelper.accessor('id', {
header: 'ID',
cell: (info) => <Truncate text={info.getValue()} position="middle" maxLength={18} />,
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
colHelper.accessor('timeExpires', {
header: 'Expires',
cell: (info) => {
const expires = info.getValue()
return expires ? <DateTime date={expires} /> : <Badge color="neutral">Never</Badge>
},
meta: { thClassName: 'lg:w-1/4' },
}),
]

const EmptyState = () => (
<TableEmptyBox border={false}>
Expand All @@ -50,151 +71,131 @@ const EmptyState = () => (

export async function clientLoader({ params }: LoaderFunctionArgs) {
const { silo } = getSiloSelector(params)
await apiQueryClient.prefetchQuery('scimTokenList', { query: { silo } })
// Use errors-allowed approach so 403s don't throw and break the loader
await queryClient.prefetchQuery(apiqErrorsAllowed('scimTokenList', { query: { silo } }))
return null
}

type ModalState =
| { kind: 'create' }
| { kind: 'created'; token: ScimClientBearerTokenValue }
| false
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes it impossible to accidentally have both modals rendered at once


export default function SiloScimTab() {
const siloSelector = useSiloSelector()
const { data } = usePrefetchedApiQuery('scimTokenList', {
query: { silo: siloSelector.silo },
})

// Order tokens by creation date, oldest first
const tokens = useMemo(
() => [...data].sort((a, b) => a.timeCreated.getTime() - b.timeCreated.getTime()),
[data]
const { data: tokensResult } = usePrefetchedQuery(
apiqErrorsAllowed('scimTokenList', { query: siloSelector })
)

const [showCreateModal, setShowCreateModal] = useState(false)
const [createdToken, setCreatedToken] = useState<{
id: string
bearerToken: string
timeCreated: Date
timeExpires?: Date | null
} | null>(null)
const [modalState, setModalState] = useState<ModalState>(false)

return (
<>
<CardBlock>
<CardBlock.Header
title="SCIM Tokens"
titleId="scim-tokens-label"
description="Tokens for authenticating requests to SCIM endpoints"
>
{
// assume that if you can see the tokens, you can create tokens
tokensResult.type === 'success' && (
<CreateButton onClick={() => setModalState({ kind: 'create' })}>
Create token
</CreateButton>
)
}
</CardBlock.Header>
<CardBlock.Body>
{match(tokensResult)
.with({ type: 'error' }, () => (
<TableEmptyBox border={false}>
<EmptyMessage
icon={<AccessToken24Icon />}
title="You do not have permission to view SCIM tokens"
body="Only fleet and silo admins can manage SCIM tokens for this silo"
/>
</TableEmptyBox>
))
.with({ type: 'success' }, ({ data }) => <TokensTable tokens={data} />)
.exhaustive()}
</CardBlock.Body>
{/* TODO: put this back!
<CardBlock.Footer>
<LearnMore href={links.scimDocs} text="SCIM" />
</CardBlock.Footer> */}
</CardBlock>

{match(modalState)
.with({ kind: 'create' }, () => (
<CreateTokenModal
siloSelector={siloSelector}
onDismiss={() => setModalState(false)}
onSuccess={(token) => setModalState({ kind: 'created', token })}
/>
))
.with({ kind: 'created' }, ({ token }) => (
<TokenCreatedModal token={token} onDismiss={() => setModalState(false)} />
))
.with(false, () => null)
.exhaustive()}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addicted to ts-pattern

</>
)
}

function TokensTable({ tokens }: { tokens: ScimClientBearerToken[] }) {
const siloSelector = useSiloSelector()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the table logic to its own component that only renders on success responses so we don't have to pollute with if-else checks.

const deleteToken = useApiMutation('scimTokenDelete', {
onSuccess() {
apiQueryClient.invalidateQueries('scimTokenList')
queryClient.invalidateEndpoint('scimTokenList')
},
})

// Order tokens by creation date, oldest first
const sortedTokens = useMemo(() => R.sortBy(tokens, (a) => a.timeCreated), [tokens])

const makeActions = useCallback(
(token: ScimClientBearerToken): MenuAction[] => [
{
label: 'Delete',
onActivate: confirmDelete({
doDelete: () =>
deleteToken.mutateAsync({
path: { tokenId: token.id },
query: { silo: siloSelector.silo },
}),
deleteToken.mutateAsync({ path: { tokenId: token.id }, query: siloSelector }),
resourceKind: 'SCIM token',
label: token.id,
}),
},
],
[deleteToken, siloSelector.silo]
)

const staticColumns = useMemo(
() => [
colHelper.accessor('id', {
header: 'ID',
cell: (info) => (
<Truncate text={info.getValue()} position="middle" maxLength={18} />
),
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
colHelper.accessor('timeExpires', {
header: 'Expires',
cell: (info) => {
const expires = info.getValue()
return expires ? (
<DateTime date={expires} />
) : (
<Badge color="neutral">Never</Badge>
)
},
meta: { thClassName: 'lg:w-1/4' },
}),
],
[]
[deleteToken, siloSelector]
)

const columns = useColsWithActions(staticColumns, makeActions, 'Copy token ID')

const table = useReactTable({
data: tokens,
data: sortedTokens,
columns,
getCoreRowModel: getCoreRowModel(),
})
// const { href, linkText } = docLinks.scim
return (
<>
<CardBlock>
<CardBlock.Header
title="SCIM Tokens"
titleId="scim-tokens-label"
description="Tokens for authenticating requests to SCIM endpoints"
>
<CreateButton onClick={() => setShowCreateModal(true)}>Create token</CreateButton>
</CardBlock.Header>
<CardBlock.Body>
{tokens.length === 0 ? (
<EmptyState />
) : (
<Table
aria-labelledby="scim-tokens-label"
table={table}
className="table-inline"
/>
)}
</CardBlock.Body>
{/* TODO: put this back!
<CardBlock.Footer>
<LearnMore href={links.scimDocs} text="SCIM" />
</CardBlock.Footer> */}
</CardBlock>

{showCreateModal && (
<CreateTokenModal
siloSelector={siloSelector}
onDismiss={() => setShowCreateModal(false)}
onSuccess={(token) => {
setShowCreateModal(false)
setCreatedToken(token)
}}
/>
)}
if (sortedTokens.length === 0) return <EmptyState />

{createdToken && (
<TokenCreatedModal token={createdToken} onDismiss={() => setCreatedToken(null)} />
)}
</>
return (
<Table aria-labelledby="scim-tokens-label" table={table} className="table-inline" />
)
}

export const handle = makeCrumb('SCIM')

function CreateTokenModal({
siloSelector,
onDismiss,
onSuccess,
}: {
siloSelector: { silo: string }
onDismiss: () => void
onSuccess: (token: {
id: string
bearerToken: string
timeCreated: Date
timeExpires?: Date | null
}) => void
onSuccess: (token: ScimClientBearerTokenValue) => void
}) {
const createToken = useApiMutation('scimTokenCreate', {
onSuccess(token) {
apiQueryClient.invalidateQueries('scimTokenList')
queryClient.invalidateEndpoint('scimTokenList')
onSuccess(token)
},
onError(err) {
Expand Down Expand Up @@ -226,12 +227,7 @@ function TokenCreatedModal({
token,
onDismiss,
}: {
token: {
id: string
bearerToken: string
timeCreated: Date
timeExpires?: Date | null
}
token: ScimClientBearerTokenValue
onDismiss: () => void
}) {
return (
Expand Down
4 changes: 2 additions & 2 deletions app/ui/lib/EmptyMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ export function EmptyMessage(props: Props) {
return (
<div className="m-4 flex max-w-[18rem] flex-col items-center text-center">
{props.icon && (
<div className="text-accent bg-accent-secondary mb-4 rounded p-1 leading-[0]">
<div className="text-accent bg-accent-secondary mb-4 rounded p-1 leading-0">
{props.icon}
</div>
)}
<h3 className="text-sans-semi-lg">{props.title}</h3>
<h3 className="text-sans-semi-lg text-balance">{props.title}</h3>
{typeof props.body === 'string' ? <EMBody>{props.body}</EMBody> : props.body}
{button}
</div>
Expand Down
9 changes: 5 additions & 4 deletions mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
paginated,
randomHex,
requireFleetAdmin,
requireFleetAdminOrSiloAdmin,
requireFleetCollab,
requireFleetViewer,
requireRole,
Expand Down Expand Up @@ -1892,17 +1893,17 @@ export const handlers = makeHandlers({

// SCIM token endpoints
scimTokenList({ query, cookies }) {
requireFleetViewer(cookies)
const silo = lookup.silo({ silo: query.silo })
requireFleetAdminOrSiloAdmin(cookies, silo.id)
// Filter by silo and strip out the siloId before returning
const tokens = db.scimTokens
.filter((t) => t.siloId === silo.id)
.map(({ siloId: _siloId, ...token }) => token)
return tokens
},
scimTokenCreate({ query, cookies }) {
requireFleetCollab(cookies)
const silo = lookup.silo({ silo: query.silo })
requireFleetAdminOrSiloAdmin(cookies, silo.id)

const newToken: Json<Api.ScimClientBearerTokenValue> = {
id: uuid(),
Expand All @@ -1917,15 +1918,15 @@ export const handlers = makeHandlers({
return json(newToken, { status: 201 })
},
scimTokenView({ path, cookies }) {
requireFleetViewer(cookies)
const token = lookupById(db.scimTokens, path.tokenId)
requireFleetAdminOrSiloAdmin(cookies, token.siloId)
// Strip out siloId before returning
const { siloId: _siloId, ...tokenResponse } = token
return tokenResponse
},
scimTokenDelete({ path, cookies }) {
requireFleetCollab(cookies)
const token = lookupById(db.scimTokens, path.tokenId)
requireFleetAdminOrSiloAdmin(cookies, token.siloId)
db.scimTokens = db.scimTokens.filter((t) => t.id !== token.id)
return 204
},
Expand Down
14 changes: 14 additions & 0 deletions mock-api/msw/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,20 @@ export function requireFleetAdmin(cookies: Record<string, string>) {
requireRole(cookies, 'fleet', FLEET_ID, 'admin')
}

/**
* Determine whether current user has fleet admin OR silo admin on a specific
* silo. Used for SCIM token operations. Do nothing if yes, throw 403 if no.
*/
export function requireFleetAdminOrSiloAdmin(
cookies: Record<string, string>,
siloId: string
) {
const user = currentUser(cookies)
const hasFleetAdmin = userHasRole(user, 'fleet', FLEET_ID, 'admin')
const hasSiloAdmin = userHasRole(user, 'silo', siloId, 'admin')
if (!hasFleetAdmin && !hasSiloAdmin) throw forbiddenErr()
}

/**
* Determine whether current user has a role on a resource by looking roles
* for the user as well as for the user's groups. Do nothing if yes, throw 403
Expand Down
6 changes: 0 additions & 6 deletions mock-api/silo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,4 @@ export const scimTokens: DbScimToken[] = [
time_expires: null,
siloId: defaultSilo.id,
},
{
id: 'c3d4e5f6-a7b8-9012-cdef-123456789012',
time_created: new Date(2025, 8, 10).toISOString(),
time_expires: null,
siloId: silos[1].id,
},
]
Loading
Loading