99import { createColumnHelper , getCoreRowModel , useReactTable } from '@tanstack/react-table'
1010import { useCallback , useMemo , useState } from 'react'
1111import { type LoaderFunctionArgs } from 'react-router'
12+ import * as R from 'remeda'
13+ import { match } from 'ts-pattern'
1214
1315import { AccessToken24Icon } from '@oxide/design-system/icons/react'
1416import { Badge } from '@oxide/design-system/ui'
1517
1618import {
17- apiQueryClient ,
19+ apiqErrorsAllowed ,
20+ queryClient ,
1821 useApiMutation ,
19- usePrefetchedApiQuery ,
22+ usePrefetchedQuery ,
2023 type ScimClientBearerToken ,
24+ type ScimClientBearerTokenValue ,
2125} from '~/api'
2226import { makeCrumb } from '~/hooks/use-crumbs'
2327import { getSiloSelector , useSiloSelector } from '~/hooks/use-params'
@@ -36,7 +40,24 @@ import { Modal } from '~/ui/lib/Modal'
3640import { TableEmptyBox } from '~/ui/lib/Table'
3741import { Truncate } from '~/ui/lib/Truncate'
3842
43+ export const handle = makeCrumb ( 'SCIM' )
44+
3945const colHelper = createColumnHelper < ScimClientBearerToken > ( )
46+ const staticColumns = [
47+ colHelper . accessor ( 'id' , {
48+ header : 'ID' ,
49+ cell : ( info ) => < Truncate text = { info . getValue ( ) } position = "middle" maxLength = { 18 } /> ,
50+ } ) ,
51+ colHelper . accessor ( 'timeCreated' , Columns . timeCreated ) ,
52+ colHelper . accessor ( 'timeExpires' , {
53+ header : 'Expires' ,
54+ cell : ( info ) => {
55+ const expires = info . getValue ( )
56+ return expires ? < DateTime date = { expires } /> : < Badge color = "neutral" > Never</ Badge >
57+ } ,
58+ meta : { thClassName : 'lg:w-1/4' } ,
59+ } ) ,
60+ ]
4061
4162const EmptyState = ( ) => (
4263 < TableEmptyBox border = { false } >
@@ -50,151 +71,131 @@ const EmptyState = () => (
5071
5172export async function clientLoader ( { params } : LoaderFunctionArgs ) {
5273 const { silo } = getSiloSelector ( params )
53- await apiQueryClient . prefetchQuery ( 'scimTokenList' , { query : { silo } } )
74+ // Use errors-allowed approach so 403s don't throw and break the loader
75+ await queryClient . prefetchQuery ( apiqErrorsAllowed ( 'scimTokenList' , { query : { silo } } ) )
5476 return null
5577}
5678
79+ type ModalState =
80+ | { kind : 'create' }
81+ | { kind : 'created' ; token : ScimClientBearerTokenValue }
82+ | false
83+
5784export default function SiloScimTab ( ) {
5885 const siloSelector = useSiloSelector ( )
59- const { data } = usePrefetchedApiQuery ( 'scimTokenList' , {
60- query : { silo : siloSelector . silo } ,
61- } )
62-
63- // Order tokens by creation date, oldest first
64- const tokens = useMemo (
65- ( ) => [ ...data ] . sort ( ( a , b ) => a . timeCreated . getTime ( ) - b . timeCreated . getTime ( ) ) ,
66- [ data ]
86+ const { data : tokensResult } = usePrefetchedQuery (
87+ apiqErrorsAllowed ( 'scimTokenList' , { query : siloSelector } )
6788 )
6889
69- const [ showCreateModal , setShowCreateModal ] = useState ( false )
70- const [ createdToken , setCreatedToken ] = useState < {
71- id : string
72- bearerToken : string
73- timeCreated : Date
74- timeExpires ?: Date | null
75- } | null > ( null )
90+ const [ modalState , setModalState ] = useState < ModalState > ( false )
91+
92+ return (
93+ < >
94+ < CardBlock >
95+ < CardBlock . Header
96+ title = "SCIM Tokens"
97+ titleId = "scim-tokens-label"
98+ description = "Tokens for authenticating requests to SCIM endpoints"
99+ >
100+ {
101+ // assume that if you can see the tokens, you can create tokens
102+ tokensResult . type === 'success' && (
103+ < CreateButton onClick = { ( ) => setModalState ( { kind : 'create' } ) } >
104+ Create token
105+ </ CreateButton >
106+ )
107+ }
108+ </ CardBlock . Header >
109+ < CardBlock . Body >
110+ { match ( tokensResult )
111+ . with ( { type : 'error' } , ( ) => (
112+ < TableEmptyBox border = { false } >
113+ < EmptyMessage
114+ icon = { < AccessToken24Icon /> }
115+ title = "You do not have permission to view SCIM tokens"
116+ body = "Only fleet and silo admins can manage SCIM tokens for this silo"
117+ />
118+ </ TableEmptyBox >
119+ ) )
120+ . with ( { type : 'success' } , ( { data } ) => < TokensTable tokens = { data } /> )
121+ . exhaustive ( ) }
122+ </ CardBlock . Body >
123+ { /* TODO: put this back!
124+ <CardBlock.Footer>
125+ <LearnMore href={links.scimDocs} text="SCIM" />
126+ </CardBlock.Footer> */ }
127+ </ CardBlock >
128+
129+ { match ( modalState )
130+ . with ( { kind : 'create' } , ( ) => (
131+ < CreateTokenModal
132+ siloSelector = { siloSelector }
133+ onDismiss = { ( ) => setModalState ( false ) }
134+ onSuccess = { ( token ) => setModalState ( { kind : 'created' , token } ) }
135+ />
136+ ) )
137+ . with ( { kind : 'created' } , ( { token } ) => (
138+ < TokenCreatedModal token = { token } onDismiss = { ( ) => setModalState ( false ) } />
139+ ) )
140+ . with ( false , ( ) => null )
141+ . exhaustive ( ) }
142+ </ >
143+ )
144+ }
76145
146+ function TokensTable ( { tokens } : { tokens : ScimClientBearerToken [ ] } ) {
147+ const siloSelector = useSiloSelector ( )
77148 const deleteToken = useApiMutation ( 'scimTokenDelete' , {
78149 onSuccess ( ) {
79- apiQueryClient . invalidateQueries ( 'scimTokenList' )
150+ queryClient . invalidateEndpoint ( 'scimTokenList' )
80151 } ,
81152 } )
82153
154+ // Order tokens by creation date, oldest first
155+ const sortedTokens = useMemo ( ( ) => R . sortBy ( tokens , ( a ) => a . timeCreated ) , [ tokens ] )
156+
83157 const makeActions = useCallback (
84158 ( token : ScimClientBearerToken ) : MenuAction [ ] => [
85159 {
86160 label : 'Delete' ,
87161 onActivate : confirmDelete ( {
88162 doDelete : ( ) =>
89- deleteToken . mutateAsync ( {
90- path : { tokenId : token . id } ,
91- query : { silo : siloSelector . silo } ,
92- } ) ,
163+ deleteToken . mutateAsync ( { path : { tokenId : token . id } , query : siloSelector } ) ,
93164 resourceKind : 'SCIM token' ,
94165 label : token . id ,
95166 } ) ,
96167 } ,
97168 ] ,
98- [ deleteToken , siloSelector . silo ]
99- )
100-
101- const staticColumns = useMemo (
102- ( ) => [
103- colHelper . accessor ( 'id' , {
104- header : 'ID' ,
105- cell : ( info ) => (
106- < Truncate text = { info . getValue ( ) } position = "middle" maxLength = { 18 } />
107- ) ,
108- } ) ,
109- colHelper . accessor ( 'timeCreated' , Columns . timeCreated ) ,
110- colHelper . accessor ( 'timeExpires' , {
111- header : 'Expires' ,
112- cell : ( info ) => {
113- const expires = info . getValue ( )
114- return expires ? (
115- < DateTime date = { expires } />
116- ) : (
117- < Badge color = "neutral" > Never</ Badge >
118- )
119- } ,
120- meta : { thClassName : 'lg:w-1/4' } ,
121- } ) ,
122- ] ,
123- [ ]
169+ [ deleteToken , siloSelector ]
124170 )
125171
126172 const columns = useColsWithActions ( staticColumns , makeActions , 'Copy token ID' )
127173
128174 const table = useReactTable ( {
129- data : tokens ,
175+ data : sortedTokens ,
130176 columns,
131177 getCoreRowModel : getCoreRowModel ( ) ,
132178 } )
133- // const { href, linkText } = docLinks.scim
134- return (
135- < >
136- < CardBlock >
137- < CardBlock . Header
138- title = "SCIM Tokens"
139- titleId = "scim-tokens-label"
140- description = "Tokens for authenticating requests to SCIM endpoints"
141- >
142- < CreateButton onClick = { ( ) => setShowCreateModal ( true ) } > Create token</ CreateButton >
143- </ CardBlock . Header >
144- < CardBlock . Body >
145- { tokens . length === 0 ? (
146- < EmptyState />
147- ) : (
148- < Table
149- aria-labelledby = "scim-tokens-label"
150- table = { table }
151- className = "table-inline"
152- />
153- ) }
154- </ CardBlock . Body >
155- { /* TODO: put this back!
156- <CardBlock.Footer>
157- <LearnMore href={links.scimDocs} text="SCIM" />
158- </CardBlock.Footer> */ }
159- </ CardBlock >
160179
161- { showCreateModal && (
162- < CreateTokenModal
163- siloSelector = { siloSelector }
164- onDismiss = { ( ) => setShowCreateModal ( false ) }
165- onSuccess = { ( token ) => {
166- setShowCreateModal ( false )
167- setCreatedToken ( token )
168- } }
169- />
170- ) }
180+ if ( sortedTokens . length === 0 ) return < EmptyState />
171181
172- { createdToken && (
173- < TokenCreatedModal token = { createdToken } onDismiss = { ( ) => setCreatedToken ( null ) } />
174- ) }
175- </ >
182+ return (
183+ < Table aria-labelledby = "scim-tokens-label" table = { table } className = "table-inline" />
176184 )
177185}
178186
179- export const handle = makeCrumb ( 'SCIM' )
180-
181187function CreateTokenModal ( {
182188 siloSelector,
183189 onDismiss,
184190 onSuccess,
185191} : {
186192 siloSelector : { silo : string }
187193 onDismiss : ( ) => void
188- onSuccess : ( token : {
189- id : string
190- bearerToken : string
191- timeCreated : Date
192- timeExpires ?: Date | null
193- } ) => void
194+ onSuccess : ( token : ScimClientBearerTokenValue ) => void
194195} ) {
195196 const createToken = useApiMutation ( 'scimTokenCreate' , {
196197 onSuccess ( token ) {
197- apiQueryClient . invalidateQueries ( 'scimTokenList' )
198+ queryClient . invalidateEndpoint ( 'scimTokenList' )
198199 onSuccess ( token )
199200 } ,
200201 onError ( err ) {
@@ -226,12 +227,7 @@ function TokenCreatedModal({
226227 token,
227228 onDismiss,
228229} : {
229- token : {
230- id : string
231- bearerToken : string
232- timeCreated : Date
233- timeExpires ?: Date | null
234- }
230+ token : ScimClientBearerTokenValue
235231 onDismiss : ( ) => void
236232} ) {
237233 return (
0 commit comments