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:
+254
-1
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user