Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10), an admin/member role model, and admin-only gating of config-mutating routes. Dashboard data stays shared across all users (per the product decision in HANDOFF.md — this is a household/self-hosted dashboard, not a multi-tenant app), so there is no per-user data isolation. Schema (backend/src/db/index.ts): - Idempotent migration adds `role` (default 'admin') and `active` (default 1) columns to `users` when missing. The 'admin' default means the pre-existing single user is backfilled to admin on deploy and keeps full access; newly created users are inserted explicitly as 'member'. Verified against a production-like old schema (columns added, existing user backfilled to admin/active). Auth + access control: - `/api/setup` creates the first user as admin. Login enforces `active` (deactivated accounts get 403) and embeds the live role in the session. - `app.authenticate` now reads role+active fresh from the DB on every request (not from the possibly-stale JWT claim), rejects inactive accounts, and stashes the role on req.user. - New `requireAdmin` (auth + role check) and `adminOnly` (role check for routes already behind the plugin-level authenticate hook) decorators. User management (admin-only, in auth.ts): - GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp password; no public signup), change role, activate/deactivate, delete. - 10-user cap enforced server-side; guard rails prevent removing the last active admin (demote/deactivate/delete) and deleting your own account; deactivating or deleting a user drops their sessions immediately. Admin-only route gating (members get 403): - integrations create/update/delete/test, tunnels create/delete, data export/import. Read routes and tunnel connect/disconnect stay open to all authenticated users, as do all the SSH/Docker/RDP tools and bookmarks (members are trusted to use the tooling, per product decision). Frontend: - api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type; role+active added to AuthUser. - Settings: new admin-only "Users" section (create form, role toggle, activate/deactivate, delete, 10-cap indicator). Nav filters the Users tab by role and guards ?tab= deep-links. Data & Backup shows an admin-only notice for members; Integrations shows a read-only banner for members. (Backend remains the real enforcement boundary.) Verified end-to-end against a throwaway backend: role assignment, member 403s on every admin-only route + 200s on shared/read routes, admin 200/201s, last-admin guards (409/400), deactivation killing an active session and blocking re-login (then reactivation restoring it), and the 10-user cap (409 on the 11th). Both frontend and backend type-check clean. Co-authored-by: Samuel James <ssamjame@amazon.com> Co-authored-by: Kiro <noreply@kiro.dev>
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
const TOKEN_KEY = 'archnest_token'
|
|
|
|
export function getToken(): string | null {
|
|
return localStorage.getItem(TOKEN_KEY)
|
|
}
|
|
|
|
export function setToken(token: string | null) {
|
|
if (token) localStorage.setItem(TOKEN_KEY, token)
|
|
else localStorage.removeItem(TOKEN_KEY)
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
status: number
|
|
constructor(status: number, message: string) {
|
|
super(message)
|
|
this.status = status
|
|
}
|
|
}
|
|
|
|
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const token = getToken()
|
|
const headers: Record<string, string> = {
|
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
...(options.headers as Record<string, string> | undefined),
|
|
}
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
|
|
const res = await fetch(`/api${path}`, { ...options, headers })
|
|
|
|
if (!res.ok) {
|
|
let message = res.statusText
|
|
try {
|
|
const body = await res.json()
|
|
message = body.error ?? message
|
|
} catch {
|
|
// ignore non-JSON error bodies
|
|
}
|
|
throw new ApiError(res.status, message)
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T
|
|
return res.json() as Promise<T>
|
|
}
|
|
|
|
export const api = {
|
|
getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'),
|
|
setup: (username: string, password: string) =>
|
|
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
|
login: (username: string, password: string) =>
|
|
apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
|
me: () => apiFetch<{ user: AuthUser }>('/auth/me'),
|
|
updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) =>
|
|
apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
|
changePassword: (currentPassword: string, newPassword: string) =>
|
|
apiFetch<{ ok: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }),
|
|
listSessions: () => apiFetch<{ sessions: AuthSession[] }>('/auth/sessions'),
|
|
revokeSession: (id: string) => apiFetch<{ ok: boolean }>(`/auth/sessions/${id}`, { method: 'DELETE' }),
|
|
logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
|
|
listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`),
|
|
|
|
listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'),
|
|
createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) =>
|
|
apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateUser: (id: number, data: Partial<{ role: 'admin' | 'member'; active: boolean }>) =>
|
|
apiFetch<{ user: ManagedUser }>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteUser: (id: number) => apiFetch<{ ok: boolean }>(`/users/${id}`, { method: 'DELETE' }),
|
|
|
|
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
|
|
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
|
|
apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateIntegration: (id: number, data: Partial<{ name: string; config: Record<string, string>; secrets: Record<string, string> }>) =>
|
|
apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteIntegration: (id: number) => apiFetch<void>(`/integrations/${id}`, { method: 'DELETE' }),
|
|
testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }),
|
|
|
|
listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'),
|
|
listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'),
|
|
createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) =>
|
|
apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }),
|
|
createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) =>
|
|
apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) =>
|
|
apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
|
|
deleteAllBookmarks: () => apiFetch<void>('/bookmarks', { method: 'DELETE' }),
|
|
|
|
listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`),
|
|
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
|
|
|
|
listTunnels: () => apiFetch<{ tunnels: Tunnel[] }>('/tunnels'),
|
|
createTunnel: (data: {
|
|
name: string
|
|
integrationId: number
|
|
mode: 'local' | 'remote' | 'dynamic'
|
|
sourcePort: number
|
|
endpointHost?: string
|
|
endpointPort?: number
|
|
autoStart?: boolean
|
|
maxRetries?: number
|
|
retryIntervalMs?: number
|
|
}) => apiFetch<{ tunnel: Tunnel }>('/tunnels', { method: 'POST', body: JSON.stringify(data) }),
|
|
deleteTunnel: (id: number) => apiFetch<void>(`/tunnels/${id}`, { method: 'DELETE' }),
|
|
connectTunnel: (id: number) =>
|
|
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }),
|
|
disconnectTunnel: (id: number) =>
|
|
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }),
|
|
|
|
listFiles: (integrationId: number, path: string) =>
|
|
apiFetch<{ path: string; entries: FileEntry[] }>(`/files/${integrationId}/list?path=${encodeURIComponent(path)}`),
|
|
readFile: (integrationId: number, path: string) =>
|
|
apiFetch<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>(
|
|
`/files/${integrationId}/content?path=${encodeURIComponent(path)}`,
|
|
),
|
|
writeFile: (integrationId: number, path: string, content: string, encoding: 'utf8' | 'base64' = 'utf8') =>
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/content`, { method: 'PUT', body: JSON.stringify({ path, content, encoding }) }),
|
|
mkdir: (integrationId: number, path: string) =>
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }),
|
|
renameFile: (integrationId: number, from: string, to: string) =>
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/rename`, { method: 'POST', body: JSON.stringify({ from, to }) }),
|
|
deleteFile: (integrationId: number, path: string, isDirectory = false) =>
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/delete`, { method: 'POST', body: JSON.stringify({ path, isDirectory }) }),
|
|
chmodFile: (integrationId: number, path: string, mode: string) =>
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/chmod`, { method: 'POST', body: JSON.stringify({ path, mode }) }),
|
|
downloadFileUrl: (integrationId: number, path: string) =>
|
|
`/api/files/${integrationId}/download?path=${encodeURIComponent(path)}`,
|
|
uploadFile: async (integrationId: number, path: string, file: File) => {
|
|
const form = new FormData()
|
|
form.append('path', path)
|
|
form.append('file', file)
|
|
const token = getToken()
|
|
const res = await fetch(`/api/files/${integrationId}/upload`, {
|
|
method: 'POST',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
body: form,
|
|
})
|
|
if (!res.ok) {
|
|
let message = res.statusText
|
|
try {
|
|
message = (await res.json()).error ?? message
|
|
} catch {
|
|
// ignore non-JSON error bodies
|
|
}
|
|
throw new ApiError(res.status, message)
|
|
}
|
|
return res.json() as Promise<{ ok: boolean; path: string }>
|
|
},
|
|
|
|
listContainers: (integrationId: number) =>
|
|
apiFetch<{ containers: Container[] }>(`/docker/${integrationId}/containers`),
|
|
containerStats: (integrationId: number, id: string) =>
|
|
apiFetch<ContainerStats>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/stats`),
|
|
containerLogs: (integrationId: number, id: string, tail = 200) =>
|
|
apiFetch<{ logs: string }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/logs?tail=${tail}`),
|
|
containerAction: (integrationId: number, id: string, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') =>
|
|
apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/${action}`, { method: 'POST' }),
|
|
removeContainer: (integrationId: number, id: string, force = false) =>
|
|
apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/remove`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ force }),
|
|
}),
|
|
|
|
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
|
|
|
|
startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) =>
|
|
apiFetch<{ transferId: string }>('/transfers', { method: 'POST', body: JSON.stringify(data) }),
|
|
listTransfers: () => apiFetch<{ transfers: TransferProgress[] }>('/transfers'),
|
|
getTransfer: (id: string) => apiFetch<TransferProgress>(`/transfers/${id}`),
|
|
cancelTransfer: (id: string) => apiFetch<{ ok: boolean }>(`/transfers/${id}/cancel`, { method: 'POST' }),
|
|
|
|
exportData: () => apiFetch<DataExport>('/data/export'),
|
|
importData: (data: DataExport) =>
|
|
apiFetch<{ ok: boolean; imported: { integrations: number; bookmarkCategories: number; bookmarks: number; tunnels: number } }>('/data/import', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
}
|
|
|
|
export interface DataExport {
|
|
version: number
|
|
exportedAt?: string
|
|
integrations: Array<{ id: number; type: string; name: string; enabled: boolean; config: Record<string, string>; secrets: Record<string, string> }>
|
|
bookmarkCategories: Array<{ id: number; name: string; icon: string | null; sortOrder: number }>
|
|
bookmarks: Array<{ categoryId: number | null; title: string; url: string; icon: string | null; favorite: boolean }>
|
|
tunnels: Array<{ name: string; integrationId: number; mode: string; sourcePort: number; endpointHost: string; endpointPort: number; autoStart: boolean; maxRetries: number; retryIntervalMs: number }>
|
|
}
|
|
|
|
export interface AuthUser {
|
|
id: number
|
|
username: string
|
|
display_name: string | null
|
|
email: string | null
|
|
avatar_data_url: string | null
|
|
role?: 'admin' | 'member'
|
|
active?: boolean
|
|
}
|
|
|
|
export interface ManagedUser {
|
|
id: number
|
|
username: string
|
|
displayName: string | null
|
|
email: string | null
|
|
role: 'admin' | 'member'
|
|
active: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
export interface AuthSession {
|
|
id: string
|
|
userAgent: string | null
|
|
ip: string | null
|
|
createdAt: string
|
|
lastSeenAt: string
|
|
current: boolean
|
|
}
|
|
|
|
export interface LoginEvent {
|
|
id: number
|
|
username: string | null
|
|
ip: string | null
|
|
userAgent: string | null
|
|
success: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
export interface Integration {
|
|
id: number
|
|
type: string
|
|
name: string
|
|
enabled: boolean
|
|
status: string
|
|
config: Record<string, string>
|
|
secretKeys: string[]
|
|
lastCheckedAt: string | null
|
|
createdAt: string
|
|
}
|
|
|
|
export interface Tunnel {
|
|
id: number
|
|
name: string
|
|
integrationId: number
|
|
mode: 'local' | 'remote' | 'dynamic'
|
|
sourcePort: number
|
|
endpointHost: string
|
|
endpointPort: number
|
|
autoStart: boolean
|
|
maxRetries: number
|
|
retryIntervalMs: number
|
|
createdAt: string
|
|
status: 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error'
|
|
error: string | null
|
|
retryCount: number
|
|
}
|
|
|
|
export interface Bookmark {
|
|
id: number
|
|
category_id: number | null
|
|
title: string
|
|
url: string
|
|
icon: string | null
|
|
favorite: number
|
|
status: string
|
|
last_checked_at: string | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface BookmarkCategory {
|
|
id: number
|
|
name: string
|
|
icon: string | null
|
|
sort_order: number
|
|
}
|
|
|
|
export interface Event {
|
|
id: number
|
|
type: string
|
|
title: string
|
|
source: string | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface FileEntry {
|
|
name: string
|
|
isDirectory: boolean
|
|
isSymlink: boolean
|
|
size: number
|
|
mode: number
|
|
mtime: number
|
|
}
|
|
|
|
export interface Container {
|
|
id: string
|
|
name: string
|
|
image: string
|
|
state: string
|
|
status: string
|
|
ports: { privatePort: number; publicPort?: number; type: string }[]
|
|
}
|
|
|
|
export interface ContainerStats {
|
|
cpuPercent: number
|
|
memUsage: number
|
|
memLimit: number
|
|
netRx: number
|
|
netTx: number
|
|
}
|
|
|
|
export interface Resource {
|
|
name: string
|
|
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
|
detail?: string
|
|
integration: string
|
|
}
|
|
|
|
export interface TransferProgress {
|
|
transferId: string
|
|
status: 'running' | 'completed' | 'failed' | 'cancelled'
|
|
sourceIntegrationId: number
|
|
destIntegrationId: number
|
|
sourcePaths: string[]
|
|
destPath: string
|
|
move: boolean
|
|
totalFiles: number
|
|
totalBytes: number
|
|
filesTransferred: number
|
|
bytesTransferred: number
|
|
currentFile: string | null
|
|
error: string | null
|
|
startedAt: number
|
|
finishedAt: number | null
|
|
}
|
|
|
|
export interface HostMetrics {
|
|
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null }
|
|
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null }
|
|
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null }
|
|
uptime: { seconds: number | null; formatted: string | null }
|
|
network: { interfaces: Array<{ name: string; ip: string; state: string }> }
|
|
system: { hostname: string | null; kernel: string | null; os: string | null }
|
|
processes: {
|
|
total: number | null
|
|
running: number | null
|
|
top: Array<{ pid: string; user: string; cpu: string; mem: string; command: string }>
|
|
}
|
|
ports: { source: 'ss' | 'netstat' | 'none'; ports: Array<{ protocol: string; localAddress: string; localPort: number; state?: string; process?: string }> }
|
|
firewall: { type: 'iptables' | 'none'; status: 'active' | 'inactive' | 'unknown'; chains: Array<{ name: string; policy: string; rules: unknown[] }> }
|
|
loginStats: {
|
|
recentLogins: Array<{ user: string; ip: string; time: string; status: string }>
|
|
failedLogins: Array<{ user: string; ip: string; time: string; status: string }>
|
|
totalLogins: number
|
|
uniqueIPs: number
|
|
}
|
|
}
|