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`,