Skip to content

Commit 1dbd08c

Browse files
committed
add my tokens page under settings
1 parent d52aec4 commit 1dbd08c

File tree

12 files changed

+217
-9
lines changed

12 files changed

+217
-9
lines changed

app/layouts/ProjectLayoutBase.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
7070
{ value: 'VPCs', path: pb.vpcs(projectSelector) },
7171
{ value: 'Floating IPs', path: pb.floatingIps(projectSelector) },
7272
{ value: 'Affinity Groups', path: pb.affinity(projectSelector) },
73-
{ value: 'Access', path: pb.projectAccess(projectSelector) },
73+
{ value: 'Project Access', path: pb.projectAccess(projectSelector) },
7474
]
7575
// filter out the entry for the path we're currently on
7676
.filter((i) => i.path !== pathname)
@@ -118,7 +118,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
118118
<Affinity16Icon /> Affinity Groups
119119
</NavLinkItem>
120120
<NavLinkItem to={pb.projectAccess(projectSelector)}>
121-
<Access16Icon /> Access
121+
<Access16Icon /> Project Access
122122
</NavLinkItem>
123123
</Sidebar.Nav>
124124
</Sidebar>

app/layouts/SettingsLayout.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
import { useMemo } from 'react'
99
import { useLocation, useNavigate } from 'react-router'
1010

11-
import { Folder16Icon, Key16Icon, Profile16Icon } from '@oxide/design-system/icons/react'
11+
import {
12+
Access16Icon,
13+
Folder16Icon,
14+
Key16Icon,
15+
Profile16Icon,
16+
} from '@oxide/design-system/icons/react'
1217

1318
import { TopBar } from '~/components/TopBar'
1419
import { makeCrumb } from '~/hooks/use-crumbs'
@@ -31,6 +36,7 @@ export default function SettingsLayout() {
3136
[
3237
{ value: 'Profile', path: pb.profile() },
3338
{ value: 'SSH Keys', path: pb.sshKeys() },
39+
{ value: 'Access Tokens', path: pb.accessTokens() },
3440
]
3541
// filter out the entry for the path we're currently on
3642
.filter((i) => i.path !== pathname)
@@ -61,6 +67,9 @@ export default function SettingsLayout() {
6167
<NavLinkItem to={pb.sshKeys()}>
6268
<Key16Icon /> SSH Keys
6369
</NavLinkItem>
70+
<NavLinkItem to={pb.accessTokens()}>
71+
<Access16Icon /> Access Tokens
72+
</NavLinkItem>
6473
</Sidebar.Nav>
6574
</Sidebar>
6675
<ContentPane />

app/layouts/SiloLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export default function SiloLayout() {
3636
{ value: 'Projects', path: pb.projects() },
3737
{ value: 'Images', path: pb.siloImages() },
3838
{ value: 'Utilization', path: pb.siloUtilization() },
39-
{ value: 'Access', path: pb.siloAccess() },
39+
{ value: 'Silo Access', path: pb.siloAccess() },
4040
]
4141
// filter out the entry for the path we're currently on
4242
.filter((i) => i.path !== pathname)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
import { createColumnHelper } from '@tanstack/react-table'
9+
import { useCallback, useMemo } from 'react'
10+
11+
import {
12+
getListQFn,
13+
queryClient,
14+
useApiMutation,
15+
useApiQueryClient,
16+
type DeviceAccessToken,
17+
} from '@oxide/api'
18+
import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react'
19+
20+
import { HL } from '~/components/HL'
21+
import { makeCrumb } from '~/hooks/use-crumbs'
22+
import { confirmDelete } from '~/stores/confirm-delete'
23+
import { addToast } from '~/stores/toast'
24+
import { getActionsCol, type MenuAction } from '~/table/columns/action-col'
25+
import { Columns } from '~/table/columns/common'
26+
import { useQueryTable } from '~/table/QueryTable'
27+
import { DateTime } from '~/ui/lib/DateTime'
28+
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
29+
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
30+
import { TipIcon } from '~/ui/lib/TipIcon'
31+
import { pb } from '~/util/path-builder'
32+
33+
const tokenList = () => getListQFn('currentUserAccessTokenList', {})
34+
export const handle = makeCrumb('Access Tokens', pb.accessTokens)
35+
36+
export async function clientLoader() {
37+
await queryClient.prefetchQuery(tokenList().optionsFn())
38+
return null
39+
}
40+
41+
const colHelper = createColumnHelper<DeviceAccessToken>()
42+
43+
export default function AccessTokensPage() {
44+
const queryClient = useApiQueryClient()
45+
46+
const { mutateAsync: deleteToken } = useApiMutation('currentUserAccessTokenDelete', {
47+
onSuccess: (_data, variables) => {
48+
queryClient.invalidateQueries('currentUserAccessTokenList')
49+
addToast(<>Access token <HL>{variables.path.tokenId}</HL> deleted</>) // prettier-ignore
50+
},
51+
})
52+
53+
const makeActions = useCallback(
54+
(token: DeviceAccessToken): MenuAction[] => [
55+
{
56+
label: 'Delete',
57+
onActivate: confirmDelete({
58+
doDelete: () => deleteToken({ path: { tokenId: token.id } }),
59+
label: token.id,
60+
extraContent:
61+
'This cannot be undone. Any application or instances of the Oxide CLI that depends on this token will need a new one.',
62+
}),
63+
},
64+
],
65+
[deleteToken]
66+
)
67+
68+
const columns = useMemo(() => {
69+
return [
70+
colHelper.accessor('id', {
71+
header: () => (
72+
<>
73+
ID{' '}
74+
<TipIcon className="ml-1.5">
75+
A database ID for the token record, not the bearer token itself.
76+
</TipIcon>
77+
</>
78+
),
79+
cell: (info) => <span className="font-mono">{info.getValue()}</span>,
80+
}),
81+
colHelper.accessor('timeCreated', Columns.timeCreated),
82+
colHelper.accessor('timeExpires', {
83+
header: 'Expires',
84+
cell: (info) => {
85+
const date = info.getValue()
86+
if (!date) return 'Never'
87+
return <DateTime date={date} />
88+
},
89+
}),
90+
getActionsCol(makeActions),
91+
]
92+
}, [makeActions])
93+
94+
const emptyState = (
95+
<EmptyMessage
96+
icon={<Key16Icon />}
97+
title="No access tokens"
98+
body="Your access tokens will appear here when they are created"
99+
/>
100+
)
101+
const { table } = useQueryTable({ query: tokenList(), columns, emptyState })
102+
103+
return (
104+
<>
105+
<PageHeader>
106+
<PageTitle icon={<Key24Icon />}>Access Tokens</PageTitle>
107+
</PageHeader>
108+
{table}
109+
</>
110+
)
111+
}

app/routes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ export const routes = createRoutesFromElements(
110110
lazy={() => import('./pages/settings/ssh-key-create').then(convert)}
111111
/>
112112
</Route>
113+
<Route
114+
path="access-tokens"
115+
lazy={() => import('./pages/settings/AccessTokensPage').then(convert)}
116+
/>
113117
</Route>
114118

115119
<Route path="system" lazy={() => import('./layouts/SystemLayout').then(convert)}>

app/util/__snapshots__/path-builder.spec.ts.snap

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
exports[`breadcrumbs 2`] = `
44
{
5+
"accessTokens (/settings/access-tokens)": [
6+
{
7+
"label": "Settings",
8+
"path": "/settings/profile",
9+
},
10+
{
11+
"label": "Access Tokens",
12+
"path": "/settings/access-tokens",
13+
},
14+
],
515
"affinity (/projects/p/affinity)": [
616
{
717
"label": "Projects",

app/util/path-builder.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ test('path builder', () => {
4141
expect(Object.fromEntries(Object.entries(pb).map(([key, fn]) => [key, fn(params)])))
4242
.toMatchInlineSnapshot(`
4343
{
44+
"accessTokens": "/settings/access-tokens",
4445
"affinity": "/projects/p/affinity",
4546
"affinityNew": "/projects/p/affinity-new",
4647
"antiAffinityGroup": "/projects/p/affinity/aag",

app/util/path-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export const pb = {
132132
sshKeys: () => '/settings/ssh-keys',
133133
sshKeysNew: () => '/settings/ssh-keys-new',
134134
sshKeyEdit: (params: PP.SshKey) => `/settings/ssh-keys/${params.sshKey}/edit`,
135+
accessTokens: () => '/settings/access-tokens',
135136

136137
deviceSuccess: () => '/device/success',
137138
}

mock-api/msw/db.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import type * as Sel from '~/api/selectors'
1818
import { commaSeries } from '~/util/str'
1919

2020
import type { Json } from '../json-type'
21+
import { siloSettings } from '../silo'
22+
import { deviceTokens } from '../token'
2123
import { internalError } from './util'
2224

2325
export const notFoundErr = (msg: string) => {
@@ -476,6 +478,7 @@ const initDb = {
476478
affinityGroupMemberLists: [...mock.affinityGroupMemberLists],
477479
antiAffinityGroups: [...mock.antiAffinityGroups],
478480
antiAffinityGroupMemberLists: [...mock.antiAffinityGroupMemberLists],
481+
deviceTokens: [...deviceTokens],
479482
disks: [...mock.disks],
480483
diskBulkImportState: new Map<string, DiskBulkImport>(),
481484
floatingIps: [...mock.floatingIps],
@@ -499,6 +502,7 @@ const initDb = {
499502
silos: [...mock.silos],
500503
siloQuotas: [...mock.siloQuotas],
501504
siloProvisioned: [...mock.siloProvisioned],
505+
siloSettings: [...siloSettings],
502506
identityProviders: [...mock.identityProviders],
503507
sleds: [...mock.sleds],
504508
switches: [...mock.switches],

mock-api/msw/handlers.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,6 +1457,15 @@ export const handlers = makeHandlers({
14571457
db.sshKeys = db.sshKeys.filter((i) => i.id !== sshKey.id)
14581458
return 204
14591459
},
1460+
currentUserAccessTokenDelete({ path }) {
1461+
// Mock delete token - find and remove from mock tokens
1462+
db.deviceTokens = db.deviceTokens.filter((token) => token.id !== path.tokenId)
1463+
return 204
1464+
},
1465+
currentUserAccessTokenList({ query }) {
1466+
// Mock token list - return dummy tokens for current user
1467+
return paginated(query, db.deviceTokens)
1468+
},
14601469
sledView({ path, cookies }) {
14611470
requireFleetViewer(cookies)
14621471
return lookup.sled(path)
@@ -1498,6 +1507,7 @@ export const handlers = makeHandlers({
14981507
db.silos.push(newSilo)
14991508
db.siloQuotas.push({ silo_id: newSilo.id, ...quotas })
15001509
db.siloProvisioned.push({ silo_id: newSilo.id, cpus: 0, memory: 0, storage: 0 })
1510+
db.siloSettings.push({ silo_id: newSilo.id, device_token_max_ttl_seconds: null })
15011511
return json(newSilo, { status: 201 })
15021512
},
15031513
siloView({ path, cookies }) {
@@ -1509,6 +1519,7 @@ export const handlers = makeHandlers({
15091519
const silo = lookup.silo(path)
15101520
db.silos = db.silos.filter((i) => i.id !== silo.id)
15111521
db.ipPoolSilos = db.ipPoolSilos.filter((i) => i.silo_id !== silo.id)
1522+
db.siloSettings = db.siloSettings.filter((i) => i.silo_id !== silo.id)
15121523
return 204
15131524
},
15141525
siloIdentityProviderList({ query, cookies }) {
@@ -1799,14 +1810,10 @@ export const handlers = makeHandlers({
17991810
alertReceiverSubscriptionRemove: NotImplemented,
18001811
alertReceiverView: NotImplemented,
18011812
antiAffinityGroupMemberInstanceView: NotImplemented,
1802-
authSettingsUpdate: NotImplemented,
1803-
authSettingsView: NotImplemented,
18041813
certificateCreate: NotImplemented,
18051814
certificateDelete: NotImplemented,
18061815
certificateList: NotImplemented,
18071816
certificateView: NotImplemented,
1808-
currentUserAccessTokenDelete: NotImplemented,
1809-
currentUserAccessTokenList: NotImplemented,
18101817
instanceSerialConsole: NotImplemented,
18111818
instanceSerialConsoleStream: NotImplemented,
18121819
instanceSshPublicKeyList: NotImplemented,
@@ -1866,6 +1873,22 @@ export const handlers = makeHandlers({
18661873
rackView: NotImplemented,
18671874
roleList: NotImplemented,
18681875
roleView: NotImplemented,
1876+
authSettingsUpdate({ body }) {
1877+
// Find settings for default silo (assume it exists)
1878+
const settingsIndex = db.siloSettings.findIndex((s) => s.silo_id === defaultSilo.id)
1879+
1880+
// Update existing settings
1881+
db.siloSettings[settingsIndex] = {
1882+
...db.siloSettings[settingsIndex],
1883+
device_token_max_ttl_seconds: body.device_token_max_ttl_seconds,
1884+
}
1885+
return db.siloSettings[settingsIndex]
1886+
},
1887+
authSettingsView() {
1888+
// Find settings for default silo (assume it exists)
1889+
const settings = db.siloSettings.find((s) => s.silo_id === defaultSilo.id)!
1890+
return settings
1891+
},
18691892
siloPolicyUpdate: NotImplemented,
18701893
siloPolicyView: NotImplemented,
18711894
siloUserList: NotImplemented,

0 commit comments

Comments
 (0)