diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx
index 361727119..db6be1832 100644
--- a/app/layouts/SiloLayout.tsx
+++ b/app/layouts/SiloLayout.tsx
@@ -36,7 +36,7 @@ export default function SiloLayout() {
{ value: 'Projects', path: pb.projects() },
{ value: 'Images', path: pb.siloImages() },
{ value: 'Utilization', path: pb.siloUtilization() },
- { value: 'Silo Access', path: pb.siloAccess() },
+ { value: 'Silo Access', path: pb.siloAccessPolicy() },
]
// filter out the entry for the path we're currently on
.filter((i) => i.path !== pathname)
@@ -67,7 +67,7 @@ export default function SiloLayout() {
Utilization
-
+
Silo Access
diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx
index b0b60e0ee..5ab8bf21a 100644
--- a/app/pages/SiloAccessPage.tsx
+++ b/app/pages/SiloAccessPage.tsx
@@ -5,52 +5,15 @@
*
* Copyright Oxide Computer Company
*/
-import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
-import { useMemo, useState } from 'react'
-
-import {
- apiQueryClient,
- byGroupThenName,
- deleteRole,
- getEffectiveRole,
- useApiMutation,
- useApiQueryClient,
- usePrefetchedApiQuery,
- useUserRows,
- type IdentityType,
- type RoleKey,
-} from '@oxide/api'
+import { apiQueryClient } from '@oxide/api'
import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react'
import { DocsPopover } from '~/components/DocsPopover'
-import { HL } from '~/components/HL'
-import {
- SiloAccessAddUserSideModal,
- SiloAccessEditUserSideModal,
-} from '~/forms/silo-access'
-import { confirmDelete } from '~/stores/confirm-delete'
-import { getActionsCol } from '~/table/columns/action-col'
-import { Table } from '~/table/Table'
-import { Badge } from '~/ui/lib/Badge'
-import { CreateButton } from '~/ui/lib/CreateButton'
-import { EmptyMessage } from '~/ui/lib/EmptyMessage'
+import { RouteTabs, Tab } from '~/components/RouteTabs'
+import { makeCrumb } from '~/hooks/use-crumbs'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
-import { TableActions, TableEmptyBox } from '~/ui/lib/Table'
-import { identityTypeLabel, roleColor } from '~/util/access'
-import { groupBy } from '~/util/array'
import { docLinks } from '~/util/links'
-
-const EmptyState = ({ onClick }: { onClick: () => void }) => (
-
- }
- title="No authorized users"
- body="Give permission to view, edit, or administer this silo"
- buttonText="Add user or group"
- onClick={onClick}
- />
-
-)
+import { pb } from '~/util/path-builder'
export async function clientLoader() {
await Promise.all([
@@ -62,106 +25,9 @@ export async function clientLoader() {
return null
}
-export const handle = { crumb: 'Silo Access' }
-
-type UserRow = {
- id: string
- identityType: IdentityType
- name: string
- siloRole: RoleKey | undefined
- effectiveRole: RoleKey
-}
-
-const colHelper = createColumnHelper()
+export const handle = makeCrumb('Silo Access', pb.siloAccessPolicy())
export default function SiloAccessPage() {
- const [addModalOpen, setAddModalOpen] = useState(false)
- const [editingUserRow, setEditingUserRow] = useState(null)
-
- const { data: siloPolicy } = usePrefetchedApiQuery('policyView', {})
- const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo')
-
- const rows = useMemo(() => {
- return groupBy(siloRows, (u) => u.id)
- .map(([userId, userAssignments]) => {
- const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName
-
- const roles = siloRole ? [siloRole] : []
-
- const { name, identityType } = userAssignments[0]
-
- const row: UserRow = {
- id: userId,
- identityType,
- name,
- siloRole,
- // we know there has to be at least one
- effectiveRole: getEffectiveRole(roles)!,
- }
-
- return row
- })
- .sort(byGroupThenName)
- }, [siloRows])
-
- const queryClient = useApiQueryClient()
- const { mutateAsync: updatePolicy } = useApiMutation('policyUpdate', {
- onSuccess: () => queryClient.invalidateQueries('policyView'),
- // TODO: handle 403
- })
-
- // TODO: checkboxes and bulk delete? not sure
- // TODO: disable delete on permissions you can't delete
-
- const columns = useMemo(
- () => [
- colHelper.accessor('name', { header: 'Name' }),
- colHelper.accessor('identityType', {
- header: 'Type',
- cell: (info) => identityTypeLabel[info.getValue()],
- }),
- colHelper.accessor('siloRole', {
- header: 'Role',
- cell: (info) => {
- const role = info.getValue()
- return role ? silo.{role} : null
- },
- }),
- // TODO: tooltips on disabled elements explaining why
- getActionsCol((row: UserRow) => [
- {
- label: 'Change role',
- onActivate: () => setEditingUserRow(row),
- disabled: !row.siloRole && "You don't have permission to change this user's role",
- },
- // TODO: only show if you have permission to do this
- {
- label: 'Delete',
- onActivate: confirmDelete({
- doDelete: () =>
- updatePolicy({
- // we know policy is there, otherwise there's no row to display
- body: deleteRole(row.id, siloPolicy),
- }),
- label: (
-
- the {row.siloRole} role for {row.name}
-
- ),
- }),
- disabled: !row.siloRole && "You don't have permission to delete this user",
- },
- ]),
- ],
- [siloPolicy, updatePolicy]
- )
-
- const tableInstance = useReactTable({
- columns,
- data: rows,
- getCoreRowModel: getCoreRowModel(),
- })
-
return (
<>
@@ -174,30 +40,10 @@ export default function SiloAccessPage() {
/>
-
- setAddModalOpen(true)}>Add user or group
-
- {siloPolicy && addModalOpen && (
- setAddModalOpen(false)}
- policy={siloPolicy}
- />
- )}
- {siloPolicy && editingUserRow?.siloRole && (
- setEditingUserRow(null)}
- policy={siloPolicy}
- name={editingUserRow.name}
- identityId={editingUserRow.id}
- identityType={editingUserRow.identityType}
- defaultValues={{ roleName: editingUserRow.siloRole }}
- />
- )}
- {rows.length === 0 ? (
- setAddModalOpen(true)} />
- ) : (
-
- )}
+
+ Policy
+ Settings
+
>
)
}
diff --git a/app/pages/SiloAccessPolicy.tsx b/app/pages/SiloAccessPolicy.tsx
new file mode 100644
index 000000000..9881ed05c
--- /dev/null
+++ b/app/pages/SiloAccessPolicy.tsx
@@ -0,0 +1,191 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
+import { useMemo, useState } from 'react'
+
+import {
+ apiQueryClient,
+ byGroupThenName,
+ deleteRole,
+ getEffectiveRole,
+ useApiMutation,
+ useApiQueryClient,
+ usePrefetchedApiQuery,
+ useUserRows,
+ type IdentityType,
+ type RoleKey,
+} from '@oxide/api'
+import { Access24Icon } from '@oxide/design-system/icons/react'
+
+import { HL } from '~/components/HL'
+import {
+ SiloAccessAddUserSideModal,
+ SiloAccessEditUserSideModal,
+} from '~/forms/silo-access'
+import { makeCrumb } from '~/hooks/use-crumbs'
+import { confirmDelete } from '~/stores/confirm-delete'
+import { getActionsCol } from '~/table/columns/action-col'
+import { Table } from '~/table/Table'
+import { Badge } from '~/ui/lib/Badge'
+import { CreateButton } from '~/ui/lib/CreateButton'
+import { EmptyMessage } from '~/ui/lib/EmptyMessage'
+import { TableActions, TableEmptyBox } from '~/ui/lib/Table'
+import { identityTypeLabel, roleColor } from '~/util/access'
+import { groupBy } from '~/util/array'
+
+const EmptyState = ({ onClick }: { onClick: () => void }) => (
+
+ }
+ title="No authorized users"
+ body="Give permission to view, edit, or administer this silo"
+ buttonText="Add user or group"
+ onClick={onClick}
+ />
+
+)
+
+export const handle = makeCrumb('Policy')
+
+export async function clientLoader() {
+ await Promise.all([
+ apiQueryClient.prefetchQuery('policyView', {}),
+ // used to resolve user names
+ apiQueryClient.prefetchQuery('userList', {}),
+ apiQueryClient.prefetchQuery('groupList', {}),
+ ])
+ return null
+}
+
+type UserRow = {
+ id: string
+ identityType: IdentityType
+ name: string
+ siloRole: RoleKey | undefined
+ effectiveRole: RoleKey
+}
+
+const colHelper = createColumnHelper()
+
+export default function SiloAccessPolicy() {
+ const [addModalOpen, setAddModalOpen] = useState(false)
+ const [editingUserRow, setEditingUserRow] = useState(null)
+
+ const { data: siloPolicy } = usePrefetchedApiQuery('policyView', {})
+ const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo')
+
+ const rows = useMemo(() => {
+ return groupBy(siloRows, (u) => u.id)
+ .map(([userId, userAssignments]) => {
+ const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName
+
+ const roles = siloRole ? [siloRole] : []
+
+ const { name, identityType } = userAssignments[0]
+
+ const row: UserRow = {
+ id: userId,
+ identityType,
+ name,
+ siloRole,
+ // we know there has to be at least one
+ effectiveRole: getEffectiveRole(roles)!,
+ }
+
+ return row
+ })
+ .sort(byGroupThenName)
+ }, [siloRows])
+
+ const queryClient = useApiQueryClient()
+ const { mutateAsync: updatePolicy } = useApiMutation('policyUpdate', {
+ onSuccess: () => queryClient.invalidateQueries('policyView'),
+ // TODO: handle 403
+ })
+
+ // TODO: checkboxes and bulk delete? not sure
+ // TODO: disable delete on permissions you can't delete
+
+ const columns = useMemo(
+ () => [
+ colHelper.accessor('name', { header: 'Name' }),
+ colHelper.accessor('identityType', {
+ header: 'Type',
+ cell: (info) => identityTypeLabel[info.getValue()],
+ }),
+ colHelper.accessor('siloRole', {
+ header: 'Role',
+ cell: (info) => {
+ const role = info.getValue()
+ return role ? silo.{role} : null
+ },
+ }),
+ // TODO: tooltips on disabled elements explaining why
+ getActionsCol((row: UserRow) => [
+ {
+ label: 'Change role',
+ onActivate: () => setEditingUserRow(row),
+ disabled: !row.siloRole && "You don't have permission to change this user's role",
+ },
+ // TODO: only show if you have permission to do this
+ {
+ label: 'Delete',
+ onActivate: confirmDelete({
+ doDelete: () =>
+ updatePolicy({
+ // we know policy is there, otherwise there's no row to display
+ body: deleteRole(row.id, siloPolicy),
+ }),
+ label: (
+
+ the {row.siloRole} role for {row.name}
+
+ ),
+ }),
+ disabled: !row.siloRole && "You don't have permission to delete this user",
+ },
+ ]),
+ ],
+ [siloPolicy, updatePolicy]
+ )
+
+ const tableInstance = useReactTable({
+ columns,
+ data: rows,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ return (
+ <>
+
+ setAddModalOpen(true)}>Add user or group
+
+ {siloPolicy && addModalOpen && (
+ setAddModalOpen(false)}
+ policy={siloPolicy}
+ />
+ )}
+ {siloPolicy && editingUserRow?.siloRole && (
+ setEditingUserRow(null)}
+ policy={siloPolicy}
+ name={editingUserRow.name}
+ identityId={editingUserRow.id}
+ identityType={editingUserRow.identityType}
+ defaultValues={{ roleName: editingUserRow.siloRole }}
+ />
+ )}
+ {rows.length === 0 ? (
+ setAddModalOpen(true)} />
+ ) : (
+
+ )}
+ >
+ )
+}
diff --git a/app/pages/SiloAccessSettings.tsx b/app/pages/SiloAccessSettings.tsx
new file mode 100644
index 000000000..73186db8d
--- /dev/null
+++ b/app/pages/SiloAccessSettings.tsx
@@ -0,0 +1,60 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
+import { useMemo } from 'react'
+
+import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api'
+
+import { makeCrumb } from '~/hooks/use-crumbs'
+import { Table } from '~/table/Table'
+import { formatDurationSeconds } from '~/util/date'
+
+const authSettingsView = apiq('authSettingsView', {})
+
+export const handle = makeCrumb('Settings')
+
+export async function clientLoader() {
+ await queryClient.prefetchQuery(authSettingsView)
+ return null
+}
+
+type SettingsRow = {
+ setting: string
+ value: string
+}
+
+const colHelper = createColumnHelper()
+const columns = [
+ colHelper.accessor('setting', { header: 'Setting' }),
+ colHelper.accessor('value', { header: 'Value' }),
+]
+
+// TODO: Need a button to open the form to update settings, but you can only see
+// it if you are a silo admin. With fleet viewer we have a pretty easy way to tell
+
+export default function SiloAccessSettings() {
+ const { data: settings } = usePrefetchedQuery(authSettingsView)
+
+ const rows = useMemo((): SettingsRow[] => {
+ const ttl = settings.deviceTokenMaxTtlSeconds
+ return [
+ {
+ setting: 'Max lifetime for device access tokens',
+ value: typeof ttl === 'number' ? formatDurationSeconds(ttl) : 'No limit',
+ },
+ ]
+ }, [settings])
+
+ const table = useReactTable({
+ columns,
+ data: rows,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ return
+}
diff --git a/app/routes.tsx b/app/routes.tsx
index f653b18af..fc9f0ef38 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -251,7 +251,17 @@ export const routes = createRoutesFromElements(
/>
- import('./pages/SiloAccessPage').then(convert)} />
+ import('./pages/SiloAccessPage').then(convert)}>
+ } />
+ import('./pages/SiloAccessPolicy').then(convert)}
+ />
+ import('./pages/SiloAccessSettings').then(convert)}
+ />
+
{/* PROJECT */}
diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap
index 2393aa324..22809cfff 100644
--- a/app/util/__snapshots__/path-builder.spec.ts.snap
+++ b/app/util/__snapshots__/path-builder.spec.ts.snap
@@ -547,10 +547,24 @@ exports[`breadcrumbs 2`] = `
"path": "/system/silos/s",
},
],
- "siloAccess (/access)": [
+ "siloAccessPolicy (/access/policy)": [
{
"label": "Silo Access",
- "path": "/access",
+ "path": "/access/policy",
+ },
+ {
+ "label": "Policy",
+ "path": "/access/policy",
+ },
+ ],
+ "siloAccessSettings (/access/settings)": [
+ {
+ "label": "Silo Access",
+ "path": "/access/policy",
+ },
+ {
+ "label": "Settings",
+ "path": "/access/settings",
},
],
"siloIdpsNew (/system/silos/s/idps-new)": [
diff --git a/app/util/date.spec.ts b/app/util/date.spec.ts
index 63382e082..6f06c17fb 100644
--- a/app/util/date.spec.ts
+++ b/app/util/date.spec.ts
@@ -6,9 +6,10 @@
* Copyright Oxide Computer Company
*/
import { subDays, subHours, subMinutes, subSeconds } from 'date-fns'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'
import {
+ formatDurationSeconds,
timeAgoAbbr,
toLocaleDateString,
toLocaleDateTimeString,
@@ -84,3 +85,23 @@ describe('timeAgoAbbr', () => {
expect(toLocaleDateTimeString(baseDate, 'ja-JP')).toEqual('2021/06/07 0:00')
})
})
+
+test('formatDurationSeconds', () => {
+ expect(formatDurationSeconds(0)).toEqual('0 seconds')
+ expect(formatDurationSeconds(1)).toEqual('1 second')
+ expect(formatDurationSeconds(30)).toEqual('30 seconds')
+ expect(formatDurationSeconds(59)).toEqual('59 seconds')
+
+ expect(formatDurationSeconds(60)).toEqual('1 minute (60 seconds)')
+ expect(formatDurationSeconds(90)).toEqual('About 2 minutes (90 seconds)')
+ expect(formatDurationSeconds(119)).toEqual('About 2 minutes (119 seconds)')
+ expect(formatDurationSeconds(120)).toEqual('2 minutes (120 seconds)')
+
+ expect(formatDurationSeconds(3600)).toEqual('1 hour (3,600 seconds)')
+ expect(formatDurationSeconds(3660)).toEqual('About 1 hour (3,660 seconds)')
+ expect(formatDurationSeconds(7100)).toEqual('About 2 hours (7,100 seconds)')
+ expect(formatDurationSeconds(7200)).toEqual('2 hours (7,200 seconds)')
+
+ expect(formatDurationSeconds(86400)).toEqual('1 day (86,400 seconds)')
+ expect(formatDurationSeconds(172800)).toEqual('2 days (172,800 seconds)')
+})
diff --git a/app/util/date.ts b/app/util/date.ts
index 9f504267d..5f85cd04a 100644
--- a/app/util/date.ts
+++ b/app/util/date.ts
@@ -7,6 +7,8 @@
*/
import { formatDistanceToNowStrict, type FormatDistanceToNowStrictOptions } from 'date-fns'
+import { pluralize } from './str'
+
// locale setup and formatDistance function copied from here and modified
// https://github.com/date-fns/date-fns/blob/56a3856/src/locale/en-US/_lib/formatDistance/index.js
@@ -53,3 +55,43 @@ export const toLocaleTimeString = (d: Date, locale?: string) =>
export const toLocaleDateTimeString = (d: Date, locale?: string) =>
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d)
+
+const SECONDS_MIN = 60
+const SECONDS_HOUR = 60 * 60
+const SECONDS_DAY = 24 * 60 * 60
+
+const numFmt = Intl.NumberFormat()
+
+/**
+ * Formats a duration unit with optional "about" prefix
+ */
+const formatDurationUnit = (
+ seconds: number,
+ unitSeconds: number,
+ unitName: string
+): string => {
+ const units = Math.round(seconds / unitSeconds)
+ const prefix = seconds % unitSeconds === 0 ? '' : 'About '
+ return `${prefix}${numFmt.format(units)} ${pluralize(unitName, units)}`
+}
+
+/**
+ * Formats seconds duration into a human-readable string.
+ * For durations longer than a minute, adds the duration in parentheses.
+ */
+export const formatDurationSeconds = (seconds: number): string => {
+ const secondsStr = `${numFmt.format(seconds)} ${pluralize('second', seconds)}`
+ if (seconds < 60) return secondsStr
+
+ let humanized: string
+
+ if (seconds >= SECONDS_DAY) {
+ humanized = formatDurationUnit(seconds, SECONDS_DAY, 'day')
+ } else if (seconds >= SECONDS_HOUR) {
+ humanized = formatDurationUnit(seconds, SECONDS_HOUR, 'hour')
+ } else {
+ humanized = formatDurationUnit(seconds, SECONDS_MIN, 'minute')
+ }
+
+ return humanized + ` (${secondsStr})`
+}
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index 202994c02..d8c0ddcf0 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -80,7 +80,8 @@ test('path builder', () => {
"samlIdp": "/system/silos/s/idps/saml/pr",
"serialConsole": "/projects/p/instances/i/serial-console",
"silo": "/system/silos/s",
- "siloAccess": "/access",
+ "siloAccessPolicy": "/access/policy",
+ "siloAccessSettings": "/access/settings",
"siloIdpsNew": "/system/silos/s/idps-new",
"siloImageEdit": "/images/im/edit",
"siloImages": "/images",
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index 1a75b7354..c9994e672 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -14,6 +14,7 @@ const projectBase = ({ project }: PP.Project) => `${pb.projects()}/${project}`
const instanceBase = ({ project, instance }: PP.Instance) =>
`${pb.instances({ project })}/${instance}`
const vpcBase = ({ project, vpc }: PP.Vpc) => `${pb.vpcs({ project })}/${vpc}`
+const siloAccessBase = '/access'
export const instanceMetricsBase = ({ project, instance }: PP.Instance) =>
`${instanceBase({ project, instance })}/metrics`
@@ -104,7 +105,8 @@ export const pb = {
`${pb.antiAffinityGroup(params)}/edit`,
siloUtilization: () => '/utilization',
- siloAccess: () => '/access',
+ siloAccessPolicy: () => `${siloAccessBase}/policy`,
+ siloAccessSettings: () => `${siloAccessBase}/settings`,
siloImages: () => '/images',
siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`,