Add SSH-managed node registry with connection testing and reauth.

Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
2026-07-18 16:39:10 +02:00
parent b93b7519ec
commit f4dc8f63d7
31 changed files with 4801 additions and 223 deletions
+254 -1
View File
@@ -114,10 +114,107 @@
.profile-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 1.5rem;
max-width: 40rem;
}
.profile-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.profile-section-title {
margin: 0;
font-size: 1.1rem;
color: var(--ctp-text);
}
.profile-form,
.users-create-form,
.reauth-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 24rem;
}
.profile-form label,
.users-create-form label,
.reauth-form label,
.users-inline-edit label {
font-size: 0.85rem;
color: var(--ctp-subtext0);
}
.profile-form input,
.users-create-form input,
.reauth-form input,
.users-inline-edit input {
padding: 0.45rem 0.6rem;
border: 1px solid var(--ctp-surface1);
border-radius: 6px;
background: var(--ctp-mantle);
color: var(--ctp-text);
}
.totp-qr {
width: 220px;
height: 220px;
border-radius: 8px;
background: #fff;
padding: 0.5rem;
}
.users-group-fieldset {
border: 1px solid var(--ctp-surface1);
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin: 0.25rem 0;
}
.users-row-actions,
.users-inline-actions,
.reauth-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.users-inline-edit {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reauth-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
padding: 1rem;
}
.reauth-modal {
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 10px;
padding: 1.25rem 1.5rem;
width: min(24rem, 100%);
box-shadow: 0 12px 40px color-mix(in srgb, var(--ctp-crust) 45%, transparent);
}
.reauth-modal h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: var(--ctp-text);
}
.content {
flex: 1;
padding: 2rem 2.5rem;
@@ -237,6 +334,162 @@
color: var(--ctp-subtext1);
}
.config-error {
margin: 0;
color: var(--ctp-red);
font-size: 0.9rem;
}
.node-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.node-list-item {
padding: 0.75rem 0;
border-bottom: 1px solid var(--ctp-surface0);
}
.node-list-heading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem 1rem;
margin-bottom: 0.25rem;
}
.node-id {
font-size: 0.8rem;
color: var(--ctp-subtext0);
}
.node-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
margin-top: 0.5rem;
}
.node-test-ssh {
padding: 0.35rem 0.7rem;
border: 1px solid var(--ctp-surface1);
border-radius: 6px;
background: var(--ctp-surface0);
color: var(--ctp-text);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.node-test-ssh:hover:not(:disabled) {
border-color: var(--ctp-overlay0);
}
.node-test-ssh:disabled {
opacity: 0.7;
cursor: default;
}
.node-test-result {
margin: 0;
font-size: 0.85rem;
line-height: 1.4;
max-width: 40rem;
word-break: break-word;
}
.node-test-result-ok {
color: var(--ctp-green);
}
.node-test-result-fail {
color: var(--ctp-red);
}
.resource-add-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
max-width: 36rem;
}
.resource-add-form .field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--ctp-subtext0);
}
.resource-add-form input,
.resource-add-form select,
.resource-add-form textarea {
padding: 0.45rem 0.6rem;
border: 1px solid var(--ctp-surface1);
border-radius: 6px;
background: var(--ctp-mantle);
color: var(--ctp-text);
font: inherit;
}
.resource-add-form input[readonly],
.resource-add-form textarea[readonly],
.resource-add-form .readonly-field {
background: var(--ctp-surface0);
color: var(--ctp-subtext0);
border-color: var(--ctp-surface0);
cursor: default;
opacity: 1;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
monospace;
font-size: 0.85rem;
}
.resource-add-form .readonly-field:focus {
outline: none;
}
.field-group {
margin: 0;
padding: 0.75rem;
border: 1px solid var(--ctp-surface0);
border-radius: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.field-group legend {
padding: 0 0.25rem;
font-size: 0.85rem;
color: var(--ctp-subtext0);
}
.radio-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
}
.radio-row label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--ctp-text);
}
.created-public-key {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.roles-panel {
display: flex;
flex-direction: column;
+99 -2
View File
@@ -24,6 +24,18 @@ function stubFetchOk() {
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: [
'nodes.read',
'nodes.exec',
'nodes.update',
'jobs.read',
'jobs.run',
'users.manage',
'secrets.manage',
'roles.manage',
],
}),
})
}
@@ -48,7 +60,25 @@ function stubFetchOk() {
if (url.includes('/api/v1/groups')) {
return Promise.resolve({
ok: true,
json: async () => ({ groups: [] }),
json: async () => ({
groups: [
{
name: 'Administrators',
scope_kind: 'group',
scope_name: 'Admin Group',
permissions: [
'nodes.read',
'nodes.exec',
'nodes.update',
'jobs.read',
'jobs.run',
'users.manage',
'secrets.manage',
'roles.manage',
],
},
],
}),
})
}
@@ -113,6 +143,13 @@ function stubFetchOk() {
})
}
if (url.includes('/api/v1/nodes')) {
return Promise.resolve({
ok: true,
json: async () => ({ nodes: [] }),
})
}
return Promise.resolve({
ok: true,
json: async () => ({}),
@@ -197,7 +234,10 @@ describe('App', () => {
).toBeInTheDocument()
expect(window.location.pathname).toBe('/profile')
expect(
screen.getByText(/Your account settings will live here/i),
screen.getByRole('heading', { name: 'Change password' }),
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: 'Authenticator (TOTP)' }),
).toBeInTheDocument()
})
@@ -229,12 +269,28 @@ describe('App', () => {
screen.getByRole('heading', { name: 'Virtual Machines', level: 2 }),
).toBeInTheDocument()
expect(window.location.pathname).toBe('/vms')
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(
screen.getByRole('tab', { name: 'Add Virtual Machines' }),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Docker' }))
expect(
screen.getByRole('heading', { name: 'Docker', level: 2 }),
).toBeInTheDocument()
expect(window.location.pathname).toBe('/docker')
expect(screen.getByRole('tab', { name: 'Add Docker' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Containers' }))
expect(window.location.pathname).toBe('/containers')
await user.click(screen.getByRole('tab', { name: 'Add Containers' }))
expect(window.location.pathname).toBe('/containers/add')
expect(await screen.findByLabelText('Node UUID')).toBeInTheDocument()
expect(screen.getByLabelText('SSH username')).toHaveValue('clustercanvas')
expect(screen.getByLabelText('KDF rounds')).toHaveValue(100)
await user.click(screen.getByRole('button', { name: 'Configuration' }))
expect(
@@ -404,6 +460,18 @@ describe('App', () => {
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: [
'nodes.read',
'nodes.exec',
'nodes.update',
'jobs.read',
'jobs.run',
'users.manage',
'secrets.manage',
'roles.manage',
],
}),
})
}
@@ -419,6 +487,27 @@ describe('App', () => {
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
})
}
if (url.includes('/api/v1/groups')) {
return Promise.resolve({
ok: true,
json: async () => ({
groups: [
{
name: 'Administrators',
scope_kind: 'group',
scope_name: 'Admin Group',
permissions: ['users.manage'],
},
{
name: 'Operators',
scope_kind: 'group',
scope_name: 'Ops',
permissions: ['nodes.read'],
},
],
}),
})
}
if (url.includes('/api/v1/users') && init?.method === 'DELETE') {
return Promise.resolve({
ok: true,
@@ -484,6 +573,14 @@ describe('App', () => {
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await user.click(deleteButtons[1])
expect(await screen.findByRole('heading', { name: 'Confirm delete' })).toBeInTheDocument()
const dialog = screen.getByRole('dialog')
await user.type(
dialog.querySelector('#reauth-password') as HTMLInputElement,
'correct horse battery staple extra',
)
await user.click(screen.getByRole('button', { name: 'Delete user' }))
expect(await screen.findByText('Configured accounts')).toBeInTheDocument()
expect(screen.queryByText('operator')).not.toBeInTheDocument()
expect(screen.getByText('Admin', { selector: '.user-badge' })).toBeInTheDocument()
+1394 -130
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
import { useState, type FormEvent } from 'react'
import type { ReauthCredentials } from './api/client'
type ReauthModalProps = {
title: string
hint?: string
requireTotp: boolean
confirmLabel?: string
onCancel: () => void
onConfirm: (credentials: ReauthCredentials) => Promise<void>
}
export function ReauthModal({
title,
hint,
requireTotp,
confirmLabel = 'Confirm',
onCancel,
onConfirm,
}: ReauthModalProps) {
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setError(null)
setIsSubmitting(true)
try {
await onConfirm({
current_password: password,
totp_code: totpCode,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Re-authentication failed')
setIsSubmitting(false)
}
}
return (
<div className="reauth-backdrop" role="presentation">
<div
className="reauth-modal"
role="dialog"
aria-modal="true"
aria-labelledby="reauth-title"
>
<h2 id="reauth-title">{title}</h2>
{hint ? <p className="config-hint">{hint}</p> : null}
<form className="reauth-form" onSubmit={(event) => void handleSubmit(event)}>
<label htmlFor="reauth-password">Password</label>
<input
id="reauth-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
{requireTotp ? (
<>
<label htmlFor="reauth-totp">Authenticator code</label>
<input
id="reauth-totp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
required
/>
</>
) : null}
{error ? (
<p className="groups-error" role="alert">
{error}
</p>
) : null}
<div className="reauth-actions">
<button type="button" className="config-action-revert" onClick={onCancel}>
Cancel
</button>
<button
type="submit"
className="config-action-save"
disabled={isSubmitting || !password}
>
{isSubmitting ? 'Checking…' : confirmLabel}
</button>
</div>
</form>
</div>
</div>
)
}
+100 -6
View File
@@ -288,17 +288,28 @@ describe('deleteUser', () => {
vi.restoreAllMocks()
})
it('sends DELETE by id and returns users', async () => {
it('sends DELETE by id with reauth credentials and returns users', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ users: [] }),
})
await deleteUser('u2', fetchMock)
await deleteUser(
'u2',
{ current_password: 'correct horse battery staple extra' },
fetchMock,
)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/users?id=u2',
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
expect.objectContaining({
method: 'DELETE',
credentials: 'include',
body: JSON.stringify({
current_password: 'correct horse battery staple extra',
totp_code: '',
}),
}),
)
})
@@ -309,9 +320,9 @@ describe('deleteUser', () => {
json: async () => ({ error: 'cannot delete the last administrator' }),
})
await expect(deleteUser('u1', fetchMock)).rejects.toThrow(
'cannot delete the last administrator',
)
await expect(
deleteUser('u1', { current_password: 'x' }, fetchMock),
).rejects.toThrow('cannot delete the last administrator')
})
})
@@ -463,3 +474,86 @@ describe('saveNetwork', () => {
)
})
})
describe('nodes API', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('fetches nodes filtered by kind', async () => {
const { fetchNodes } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ nodes: [] }),
})
await fetchNodes('container', fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes?kind=container',
expect.objectContaining({ credentials: 'include' }),
)
})
it('creates a node with generate options', async () => {
const { createNode } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
node: {
id: '11111111-2222-4333-8444-555555555555',
kind: 'container',
name: 'ct1',
host_ip: '10.0.0.1',
username: 'clustercanvas',
group_name: 'Administrators',
public_key: 'ssh-ed25519 AAAA',
key_algo: 'ed25519',
created_at: '2026-01-01T00:00:00.000Z',
},
}),
})
await createNode(
{
kind: 'container',
name: 'ct1',
host_ip: '10.0.0.1',
username: 'clustercanvas',
group_name: 'Administrators',
generate: { algorithm: 'ed25519', kdf_rounds: 100 },
},
fetchMock,
)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes',
expect.objectContaining({
method: 'POST',
credentials: 'include',
}),
)
})
it('posts to the SSH test endpoint', async () => {
const { testNodeSSH } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ ok: false, message: 'connection refused' }),
})
const result = await testNodeSSH(
'11111111-2222-4333-8444-555555555555',
fetchMock,
)
expect(result).toEqual({ ok: false, message: 'connection refused' })
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes/11111111-2222-4333-8444-555555555555/test-ssh',
expect.objectContaining({
method: 'POST',
credentials: 'include',
}),
)
})
})
+289 -4
View File
@@ -84,6 +84,32 @@ export type MeResponse = {
user_id: string
username: string
groups: ReadonlyArray<string>
totp_confirmed: boolean
totp_enabled: boolean
permissions: ReadonlyArray<string>
}
export type ReauthCredentials = {
current_password: string
totp_code?: string
}
export type CreateUserRequest = {
username: string
password: string
group_names?: ReadonlyArray<string>
}
export type PatchUserRequest = {
group_names?: ReadonlyArray<string>
password?: string
disable_totp?: boolean
}
export const REAUTH_REQUIRED = 'reauth_required'
export function isReauthRequiredError(error: unknown): boolean {
return error instanceof Error && error.message === REAUTH_REQUIRED
}
export type NetworkPreviewResponse = {
@@ -221,13 +247,20 @@ export async function fetchUsers(
return (await response.json()) as UsersResponse
}
export async function deleteUser(
id: string,
export async function createUser(
payload: CreateUserRequest,
fetchImpl: typeof fetch = fetch,
): Promise<UsersResponse> {
const response = await apiFetch(
`/api/v1/users?id=${encodeURIComponent(id)}`,
{ method: 'DELETE' },
'/api/v1/users',
{
method: 'POST',
body: JSON.stringify({
username: payload.username,
password: payload.password,
group_names: payload.group_names ?? [],
}),
},
fetchImpl,
)
if (!response.ok) {
@@ -236,6 +269,153 @@ export async function deleteUser(
return (await response.json()) as UsersResponse
}
export async function patchUser(
id: string,
payload: PatchUserRequest,
fetchImpl: typeof fetch = fetch,
): Promise<UsersResponse> {
const response = await apiFetch(
`/api/v1/users?id=${encodeURIComponent(id)}`,
{
method: 'PATCH',
body: JSON.stringify(payload),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as UsersResponse
}
export async function deleteUser(
id: string,
credentials: ReauthCredentials,
fetchImpl: typeof fetch = fetch,
): Promise<UsersResponse> {
const response = await apiFetch(
`/api/v1/users?id=${encodeURIComponent(id)}`,
{
method: 'DELETE',
body: JSON.stringify({
current_password: credentials.current_password,
totp_code: credentials.totp_code ?? '',
}),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as UsersResponse
}
export async function reauth(
password: string,
totpCode = '',
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const response = await apiFetch(
'/api/v1/auth/reauth',
{
method: 'POST',
body: JSON.stringify({ password, totp_code: totpCode }),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
}
export async function changeMyPassword(
currentPassword: string,
newPassword: string,
totpCode = '',
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const response = await apiFetch(
'/api/v1/me/password',
{
method: 'PUT',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
totp_code: totpCode,
}),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
}
export async function beginMyTOTP(
credentials: ReauthCredentials,
fetchImpl: typeof fetch = fetch,
): Promise<SetupTOTPBeginResponse> {
const response = await apiFetch(
'/api/v1/me/totp/begin',
{
method: 'POST',
body: JSON.stringify({
current_password: credentials.current_password,
totp_code: credentials.totp_code ?? '',
}),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as SetupTOTPBeginResponse
}
export async function confirmMyTOTP(
secret: string,
code: string,
credentials: ReauthCredentials,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const response = await apiFetch(
'/api/v1/me/totp/confirm',
{
method: 'POST',
body: JSON.stringify({
secret,
code,
current_password: credentials.current_password,
totp_code: credentials.totp_code ?? '',
}),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
}
export async function disableMyTOTP(
credentials: ReauthCredentials,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const response = await apiFetch(
'/api/v1/me/totp/disable',
{
method: 'POST',
body: JSON.stringify({
current_password: credentials.current_password,
totp_code: credentials.totp_code ?? '',
}),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
}
export async function fetchSecurity(
fetchImpl: typeof fetch = fetch,
): Promise<SecurityResponse> {
@@ -424,3 +604,108 @@ export async function fetchMe(
}
return (await response.json()) as MeResponse
}
export type NodeKind = 'container' | 'vm' | 'docker'
export type Node = {
id: string
kind: NodeKind
name: string
host_ip: string
username: string
group_name: string
public_key: string
key_algo: string
created_at: string
}
export type NodesResponse = {
nodes: ReadonlyArray<Node>
}
export type NodeResponse = {
node: Node
}
export type CreateNodeGenerate = {
algorithm: 'ed25519' | 'rsa'
rsa_bits?: number
kdf_rounds?: number
}
export type CreateNodeRequest = {
id?: string
kind: NodeKind
name: string
host_ip: string
username: string
group_name?: string
new_group?: { name: string }
generate?: CreateNodeGenerate
private_key?: string
}
export async function fetchNodes(
kind?: NodeKind,
fetchImpl: typeof fetch = fetch,
): Promise<NodesResponse> {
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodesResponse
}
export async function createNode(
payload: CreateNodeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<NodeResponse> {
const response = await apiFetch(
'/api/v1/nodes',
{
method: 'POST',
body: JSON.stringify(payload),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeResponse
}
export async function fetchNode(
id: string,
fetchImpl: typeof fetch = fetch,
): Promise<NodeResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}`,
{},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeResponse
}
export type NodeSSHTestResponse = {
ok: boolean
message: string
}
export async function testNodeSSH(
id: string,
fetchImpl: typeof fetch = fetch,
): Promise<NodeSSHTestResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}/test-ssh`,
{ method: 'POST' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeSSHTestResponse
}
+19 -1
View File
@@ -7,6 +7,9 @@ describe('pathFor', () => {
expect(pathFor('vms')).toBe('/vms')
expect(pathFor('docker')).toBe('/docker')
expect(pathFor('containers')).toBe('/containers')
expect(pathFor('vms', 'overview', 'security')).toBe('/vms/security')
expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add')
expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add')
expect(pathFor('profile')).toBe('/profile')
expect(pathFor('setup')).toBe('/setup')
expect(pathFor('login')).toBe('/login')
@@ -24,6 +27,9 @@ describe('parseLocation', () => {
'/vms',
'/docker',
'/containers',
'/vms/security',
'/containers/add',
'/docker/overview',
'/profile',
'/setup',
'/login',
@@ -37,7 +43,9 @@ describe('parseLocation', () => {
'/configuration/advanced',
]) {
const location = parseLocation(path)
expect(pathFor(location.section, location.configTab)).toBe(path)
expect(
pathFor(location.section, location.configTab, location.resourceTab),
).toBe(path === '/docker/overview' ? '/docker' : path)
}
})
@@ -45,18 +53,22 @@ describe('parseLocation', () => {
expect(parseLocation('/nope')).toEqual({
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
})
expect(parseLocation('/configuration/unknown')).toEqual({
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
})
expect(parseLocation('/vms/extra')).toEqual({
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
})
expect(parseLocation('/docker/extra')).toEqual({
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
})
})
@@ -64,6 +76,12 @@ describe('parseLocation', () => {
expect(parseLocation('/configuration/groups/')).toEqual({
section: 'configuration',
configTab: 'groups',
resourceTab: 'overview',
})
expect(parseLocation('/containers/add/')).toEqual({
section: 'containers',
configTab: 'overview',
resourceTab: 'add',
})
})
})
+59 -28
View File
@@ -18,9 +18,14 @@ export type ConfigTabId =
| 'network'
| 'advanced'
export type ResourceTabId = 'overview' | 'security' | 'add'
export type ResourceSectionId = 'containers' | 'vms' | 'docker'
export type AppLocation = {
section: SectionId
configTab: ConfigTabId
resourceTab: ResourceTabId
}
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
@@ -34,16 +39,37 @@ const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
'advanced',
])
const RESOURCE_TAB_IDS: ReadonlySet<string> = new Set([
'overview',
'security',
'add',
])
const RESOURCE_SECTION_IDS: ReadonlySet<string> = new Set([
'containers',
'vms',
'docker',
])
const DEFAULT_LOCATION: AppLocation = {
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
}
function isConfigTabId(value: string): value is ConfigTabId {
return CONFIG_TAB_IDS.has(value)
}
/** Parse a pathname into section + config tab. Unknown paths fall back to Overview. */
function isResourceTabId(value: string): value is ResourceTabId {
return RESOURCE_TAB_IDS.has(value)
}
export function isResourceSectionId(value: SectionId): value is ResourceSectionId {
return RESOURCE_SECTION_IDS.has(value)
}
/** Parse a pathname into section + config/resource tab. Unknown paths fall back to Overview. */
export function parseLocation(pathname: string): AppLocation {
const normalized = pathname.replace(/\/+$/, '') || '/'
const segments = normalized.split('/').filter(Boolean)
@@ -55,45 +81,53 @@ export function parseLocation(pathname: string): AppLocation {
const [first, second] = segments
if (first === 'setup' && segments.length === 1) {
return { section: 'setup', configTab: 'overview' }
return { section: 'setup', configTab: 'overview', resourceTab: 'overview' }
}
if (first === 'login' && segments.length === 1) {
return { section: 'login', configTab: 'overview' }
}
if (first === 'vms' && segments.length === 1) {
return { section: 'vms', configTab: 'overview' }
}
if (first === 'docker' && segments.length === 1) {
return { section: 'docker', configTab: 'overview' }
}
if (first === 'containers' && segments.length === 1) {
return { section: 'containers', configTab: 'overview' }
return { section: 'login', configTab: 'overview', resourceTab: 'overview' }
}
if (first === 'profile' && segments.length === 1) {
return { section: 'profile', configTab: 'overview' }
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
}
if (RESOURCE_SECTION_IDS.has(first)) {
const section = first as ResourceSectionId
if (segments.length === 1) {
return { section, configTab: 'overview', resourceTab: 'overview' }
}
if (segments.length === 2 && isResourceTabId(second)) {
return { section, configTab: 'overview', resourceTab: second }
}
return { ...DEFAULT_LOCATION }
}
if (first === 'configuration') {
if (segments.length === 1) {
return { section: 'configuration', configTab: 'overview' }
return {
section: 'configuration',
configTab: 'overview',
resourceTab: 'overview',
}
}
if (segments.length === 2 && isConfigTabId(second)) {
return { section: 'configuration', configTab: second }
return {
section: 'configuration',
configTab: second,
resourceTab: 'overview',
}
}
}
return { ...DEFAULT_LOCATION }
}
/** Build the pathname for a section (and optional config tab). */
/** Build the pathname for a section (and optional config or resource tab). */
export function pathFor(
section: SectionId,
configTab: ConfigTabId = 'overview',
resourceTab: ResourceTabId = 'overview',
): string {
if (section === 'overview') {
return '/'
@@ -104,18 +138,15 @@ export function pathFor(
if (section === 'login') {
return '/login'
}
if (section === 'vms') {
return '/vms'
}
if (section === 'docker') {
return '/docker'
}
if (section === 'containers') {
return '/containers'
}
if (section === 'profile') {
return '/profile'
}
if (isResourceSectionId(section)) {
if (resourceTab === 'overview') {
return `/${section}`
}
return `/${section}/${resourceTab}`
}
if (configTab === 'overview') {
return '/configuration'
}