feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
/**
* Alerts API client — TypeScript functions for alert rules, notification channels,
* and alert events. Uses the shared axios instance from api.ts.
*/
import { api } from './api'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AlertRule {
id: string
tenant_id: string
device_id: string | null
group_id: string | null
name: string
metric: string
operator: string
threshold: number
duration_polls: number
severity: 'critical' | 'warning' | 'info'
enabled: boolean
is_default: boolean
channel_ids: string[]
created_at: string
}
export interface NotificationChannel {
id: string
tenant_id: string
name: string
channel_type: 'email' | 'webhook' | 'slack'
smtp_host?: string | null
smtp_port?: number | null
smtp_user?: string | null
smtp_use_tls?: boolean
from_address?: string | null
to_address?: string | null
webhook_url?: string | null
slack_webhook_url?: string | null
created_at: string
}
export interface AlertEvent {
id: string
rule_id: string | null
device_id: string
tenant_id: string
status: 'firing' | 'resolved' | 'flapping'
severity: string
metric: string | null
value: number | null
threshold: number | null
message: string | null
is_flapping: boolean
acknowledged_at: string | null
silenced_until: string | null
fired_at: string
resolved_at: string | null
device_hostname?: string
rule_name?: string
}
export interface AlertsListResponse {
items: AlertEvent[]
total: number
page: number
per_page: number
}
export interface AlertRuleCreateData {
name: string
metric: string
operator: string
threshold: number
duration_polls?: number
severity?: string
device_id?: string | null
group_id?: string | null
channel_ids?: string[]
enabled?: boolean
}
export interface ChannelCreateData {
name: string
channel_type: 'email' | 'webhook' | 'slack'
smtp_host?: string
smtp_port?: number
smtp_user?: string
smtp_password?: string
smtp_use_tls?: boolean
from_address?: string
to_address?: string
webhook_url?: string
slack_webhook_url?: string
}
export interface AlertsFilterParams {
status?: string
severity?: string
device_id?: string
rule_id?: string
start_date?: string
end_date?: string
page?: number
per_page?: number
}
// ---------------------------------------------------------------------------
// Alert Rules
// ---------------------------------------------------------------------------
export const alertsApi = {
// -- Alert Rules --
getAlertRules: (tenantId: string, params?: { enabled?: boolean; metric?: string }) =>
api
.get<AlertRule[]>(`/api/tenants/${tenantId}/alert-rules`, { params })
.then((r) => r.data),
createAlertRule: (tenantId: string, data: AlertRuleCreateData) =>
api
.post<AlertRule>(`/api/tenants/${tenantId}/alert-rules`, data)
.then((r) => r.data),
updateAlertRule: (tenantId: string, ruleId: string, data: AlertRuleCreateData) =>
api
.put<AlertRule>(`/api/tenants/${tenantId}/alert-rules/${ruleId}`, data)
.then((r) => r.data),
deleteAlertRule: (tenantId: string, ruleId: string) =>
api.delete(`/api/tenants/${tenantId}/alert-rules/${ruleId}`).then((r) => r.data),
toggleAlertRule: (tenantId: string, ruleId: string) =>
api
.patch<{ id: string; enabled: boolean }>(
`/api/tenants/${tenantId}/alert-rules/${ruleId}/toggle`,
)
.then((r) => r.data),
// -- Notification Channels --
getNotificationChannels: (tenantId: string) =>
api
.get<NotificationChannel[]>(`/api/tenants/${tenantId}/notification-channels`)
.then((r) => r.data),
createChannel: (tenantId: string, data: ChannelCreateData) =>
api
.post<NotificationChannel>(`/api/tenants/${tenantId}/notification-channels`, data)
.then((r) => r.data),
updateChannel: (tenantId: string, channelId: string, data: ChannelCreateData) =>
api
.put<NotificationChannel>(
`/api/tenants/${tenantId}/notification-channels/${channelId}`,
data,
)
.then((r) => r.data),
deleteChannel: (tenantId: string, channelId: string) =>
api
.delete(`/api/tenants/${tenantId}/notification-channels/${channelId}`)
.then((r) => r.data),
testChannel: (tenantId: string, channelId: string) =>
api
.post<{ status: string; message: string }>(
`/api/tenants/${tenantId}/notification-channels/${channelId}/test`,
)
.then((r) => r.data),
testSmtp: (
tenantId: string,
data: {
smtp_host: string
smtp_port: number
smtp_user?: string
smtp_password?: string
smtp_use_tls: boolean
from_address: string
to_address: string
},
) =>
api
.post<{ success: boolean; message: string }>(
`/api/tenants/${tenantId}/notification-channels/test-smtp`,
data,
)
.then((r) => r.data),
// -- Alert Events --
getAlerts: (tenantId: string, params?: AlertsFilterParams) =>
api
.get<AlertsListResponse>(`/api/tenants/${tenantId}/alerts`, { params })
.then((r) => r.data),
getActiveAlertCount: (tenantId: string) =>
api
.get<{ count: number }>(`/api/tenants/${tenantId}/alerts/active-count`)
.then((r) => r.data),
acknowledgeAlert: (tenantId: string, alertId: string) =>
api
.post<{ status: string; message: string }>(
`/api/tenants/${tenantId}/alerts/${alertId}/acknowledge`,
)
.then((r) => r.data),
silenceAlert: (tenantId: string, alertId: string, durationMinutes: number) =>
api
.post<{ status: string; message: string }>(
`/api/tenants/${tenantId}/alerts/${alertId}/silence`,
{ duration_minutes: durationMinutes },
)
.then((r) => r.data),
getDeviceAlerts: (tenantId: string, deviceId: string, params?: AlertsFilterParams) =>
api
.get<AlertsListResponse>(`/api/tenants/${tenantId}/devices/${deviceId}/alerts`, {
params,
})
.then((r) => r.data),
}

1009
frontend/src/lib/api.ts Normal file

File diff suppressed because it is too large Load Diff

258
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,258 @@
import { create } from 'zustand'
import { authApi, type UserMe } from './api'
import { keyStore } from './crypto/keyStore'
import { deriveKeysInWorker } from './crypto/keys'
import { SRPClient } from './crypto/srp'
import { parseSecretKey } from './crypto/secretKey'
import { assertWebCryptoAvailable } from './crypto/registration'
import { getAuthErrorMessage } from './errors'
interface AuthState {
user: UserMe | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
needsSecretKey: boolean // True when SRP user on new device needs Secret Key
isDerivingKeys: boolean // True during PBKDF2 computation
isUpgrading: boolean // True when legacy bcrypt user is upgrading to SRP
pendingUpgradeEmail: string | null // Email of user being upgraded
pendingUpgradePassword: string | null // Password of user being upgraded (for SRP derivation)
login: (email: string, password: string) => Promise<void>
srpLogin: (email: string, password: string, secretKeyInput?: string) => Promise<void>
logout: () => Promise<void>
checkAuth: () => Promise<void>
clearError: () => void
clearNeedsSecretKey: () => void
completeUpgrade: () => Promise<void>
cancelUpgrade: () => void
}
export const useAuth = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true,
error: null,
needsSecretKey: false,
isDerivingKeys: false,
isUpgrading: false,
pendingUpgradeEmail: null,
pendingUpgradePassword: null,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
try {
const result = await authApi.login({ email, password })
// Check if this is a legacy bcrypt user needing SRP upgrade
if (result.auth_upgrade_required) {
// Only show upgrade dialog if Web Crypto is available (requires HTTPS or localhost).
// If not, skip upgrade and proceed with bcrypt session — upgrade happens next HTTPS visit.
const hasCrypto = typeof crypto !== 'undefined' && !!crypto.subtle
if (hasCrypto) {
set({
isLoading: false,
isUpgrading: true,
pendingUpgradeEmail: email,
pendingUpgradePassword: password,
})
return
}
// Fall through to complete login without SRP upgrade
}
const user = await authApi.me()
set({ user, isAuthenticated: true, isLoading: false, error: null })
} catch (err: unknown) {
// Check if 409 srp_required -- redirect to SRP flow
const axiosErr = err as { response?: { status?: number; data?: { detail?: string } } }
if (
axiosErr?.response?.status === 409 &&
axiosErr?.response?.data?.detail === 'srp_required'
) {
// User has SRP auth -- try SRP flow
return get().srpLogin(email, password)
}
const message = getAuthErrorMessage(err)
set({
isLoading: false,
isAuthenticated: false,
user: null,
error: message,
})
throw new Error(message)
}
},
srpLogin: async (email: string, password: string, secretKeyInput?: string) => {
set({ isLoading: true, isDerivingKeys: true, error: null })
try {
// 0. Verify Web Crypto API is available (requires HTTPS or localhost)
assertWebCryptoAvailable()
// 1. Get Secret Key (from IndexedDB or user input)
let secretKeyBytes: Uint8Array | null = await keyStore.getSecretKey(email)
if (!secretKeyBytes && secretKeyInput) {
secretKeyBytes = parseSecretKey(secretKeyInput)
if (!secretKeyBytes) {
set({ error: 'Invalid Secret Key format', isLoading: false, isDerivingKeys: false })
return
}
}
if (!secretKeyBytes) {
set({ needsSecretKey: true, isLoading: false, isDerivingKeys: false })
return
}
// 2. SRP Step 1: init (returns salt, B, session_id, AND key derivation salts)
const { salt, server_public, session_id, pbkdf2_salt, hkdf_salt } =
await authApi.srpInit(email)
// 3. Decode base64 salts returned by /srp/init from user_key_sets
const pbkdf2SaltBytes = Uint8Array.from(atob(pbkdf2_salt), (c) => c.charCodeAt(0))
const hkdfSaltBytes = Uint8Array.from(atob(hkdf_salt), (c) => c.charCodeAt(0))
// 4. Derive keys in Web Worker (PBKDF2 650K iterations)
const { auk, srpX } = await deriveKeysInWorker({
masterPassword: password,
secretKeyBytes,
email,
accountId: email, // Use email as accountId for key derivation
pbkdf2Salt: pbkdf2SaltBytes,
hkdfSalt: hkdfSaltBytes,
})
set({ isDerivingKeys: false })
// 5. SRP handshake
const srpClient = new SRPClient(email)
const { clientProof } = await srpClient.computeSession(srpX, salt, server_public)
// 6. SRP Step 2: verify
const result = await authApi.srpVerify({
email,
session_id,
client_public: srpClient.getPublicEphemeral(),
client_proof: clientProof,
})
// 7. Verify server proof M2
const serverValid = await srpClient.verifyServerProof(result.server_proof)
if (!serverValid) {
throw new Error('Server authentication failed')
}
// 8. Store AUK and unlock key set
keyStore.setAUK(auk)
// TODO (Phase 30): Decrypt encrypted_key_set with AUK to get vault key
// 9. Store Secret Key in IndexedDB for future logins on this device
await keyStore.storeSecretKey(email, secretKeyBytes)
// 10. Fetch user profile
const user = await authApi.me()
set({ user, isAuthenticated: true, isLoading: false, needsSecretKey: false })
} catch (err) {
keyStore.clearAll()
const axErr = err as { response?: { status?: number; data?: { detail?: string } } }
const detail = axErr?.response?.data?.detail ?? ''
let message: string
if (axErr?.response?.status === 401) {
// SRP proof failed — wrong password, wrong Secret Key, or stale credentials
message = 'Sign in failed. Check your password and Secret Key. If you lost your Secret Key, use "Forgot password?" to reset your account and get a new one.'
} else if (detail.includes('initialization failed')) {
message = 'Authentication setup failed. Please try again or reset your password.'
} else {
message = getAuthErrorMessage(err)
}
set({ isLoading: false, isDerivingKeys: false, error: message })
throw err
}
},
completeUpgrade: async () => {
// Called after SRP registration completes during upgrade flow.
// The user already has a valid session cookie from the bcrypt login,
// so just fetch the profile to complete authentication. A full SRP
// login will happen naturally on their next session.
set({ isUpgrading: false, pendingUpgradeEmail: null, pendingUpgradePassword: null })
try {
const user = await authApi.me()
set({ user, isAuthenticated: true, isLoading: false, error: null })
} catch (err) {
set({ isLoading: false, error: getAuthErrorMessage(err) })
throw err
}
},
cancelUpgrade: () => {
set({
isUpgrading: false,
pendingUpgradeEmail: null,
pendingUpgradePassword: null,
isLoading: false,
})
},
logout: async () => {
keyStore.clearAll()
set({ isLoading: true })
try {
await authApi.logout()
} catch {
// ignore logout errors
} finally {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
needsSecretKey: false,
isDerivingKeys: false,
isUpgrading: false,
pendingUpgradeEmail: null,
pendingUpgradePassword: null,
})
}
},
checkAuth: async () => {
set({ isLoading: true })
try {
const user = await authApi.me()
set({ user, isAuthenticated: true, isLoading: false, error: null })
} catch {
set({ user: null, isAuthenticated: false, isLoading: false, error: null })
}
},
clearError: () => set({ error: null }),
clearNeedsSecretKey: () => set({ needsSecretKey: false }),
}))
// Role helpers
export function isSuperAdmin(user: UserMe | null): boolean {
return user?.role === 'super_admin'
}
export function isTenantAdmin(user: UserMe | null): boolean {
return user?.role === 'tenant_admin' || user?.role === 'super_admin'
}
export function isOperator(user: UserMe | null): boolean {
return (
user?.role === 'operator' ||
user?.role === 'tenant_admin' ||
user?.role === 'super_admin'
)
}
export function canWrite(user: UserMe | null): boolean {
return isOperator(user)
}
export function canDelete(user: UserMe | null): boolean {
return isTenantAdmin(user)
}

View File

@@ -0,0 +1,176 @@
/**
* Certificates API client -- TypeScript functions for the Internal Certificate
* Authority: CA lifecycle, device cert signing, deployment, rotation, and revocation.
* Uses the shared axios instance from api.ts.
*/
import { api } from './api'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CAResponse {
id: string
tenant_id: string
common_name: string
fingerprint_sha256: string
serial_number: string
not_valid_before: string
not_valid_after: string
created_at: string
}
export interface DeviceCertResponse {
id: string
tenant_id: string
device_id: string
ca_id: string
common_name: string
fingerprint_sha256: string
serial_number: string
not_valid_before: string
not_valid_after: string
status:
| 'issued'
| 'deploying'
| 'deployed'
| 'expiring'
| 'expired'
| 'revoked'
| 'superseded'
deployed_at: string | null
created_at: string
updated_at: string
}
export interface CertDeployResponse {
success: boolean
device_id: string
cert_name_on_device?: string
error?: string
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build query params object, including tenant_id when provided. */
function tenantParams(
tenantId?: string,
extra?: Record<string, string>,
): Record<string, string> {
const params: Record<string, string> = {}
if (tenantId) params.tenant_id = tenantId
if (extra) Object.assign(params, extra)
return params
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
export const certificatesApi = {
/** Get the tenant's CA (returns null if no CA exists). */
getCA: async (tenantId?: string): Promise<CAResponse | null> => {
try {
const { data } = await api.get<CAResponse>('/api/certificates/ca', {
params: tenantParams(tenantId),
})
return data
} catch (err: any) {
if (err?.response?.status === 404) return null
throw err
}
},
/** Initialize a new CA for the tenant. */
createCA: (
commonName?: string,
validityYears?: number,
tenantId?: string,
) =>
api
.post<CAResponse>(
'/api/certificates/ca',
{
common_name: commonName ?? 'Portal Root CA',
validity_years: validityYears ?? 10,
},
{ params: tenantParams(tenantId) },
)
.then((r) => r.data),
/** Download the CA certificate in PEM format. */
getCACertPEM: (tenantId?: string) =>
api
.get<string>('/api/certificates/ca/pem', {
responseType: 'text',
params: tenantParams(tenantId),
})
.then((r) => r.data),
/** Sign a certificate for a specific device. */
signCert: (deviceId: string, validityDays?: number, tenantId?: string) =>
api
.post<DeviceCertResponse>(
'/api/certificates/sign',
{
device_id: deviceId,
validity_days: validityDays ?? 730,
},
{ params: tenantParams(tenantId) },
)
.then((r) => r.data),
/** Deploy an already-signed certificate to its device. */
deployCert: (certId: string, tenantId?: string) =>
api
.post<CertDeployResponse>(
`/api/certificates/${certId}/deploy`,
undefined,
{ params: tenantParams(tenantId) },
)
.then((r) => r.data),
/** Bulk deploy certificates (sign + deploy) to multiple devices. */
bulkDeploy: (deviceIds: string[], tenantId?: string) =>
api
.post<CertDeployResponse[]>(
'/api/certificates/deploy/bulk',
{ device_ids: deviceIds },
{ params: tenantParams(tenantId) },
)
.then((r) => r.data),
/** List device certificates (optionally filtered by device). */
getDeviceCerts: (deviceId?: string, tenantId?: string) =>
api
.get<DeviceCertResponse[]>('/api/certificates/devices', {
params: tenantParams(
tenantId,
deviceId ? { device_id: deviceId } : undefined,
),
})
.then((r) => r.data),
/** Rotate a deployed certificate (supersede old, sign + deploy new). */
rotateCert: (certId: string, tenantId?: string) =>
api
.post<CertDeployResponse>(
`/api/certificates/${certId}/rotate`,
undefined,
{ params: tenantParams(tenantId) },
)
.then((r) => r.data),
/** Revoke a certificate. */
revokeCert: (certId: string, tenantId?: string) =>
api
.post<DeviceCertResponse>(
`/api/certificates/${certId}/revoke`,
undefined,
{ params: tenantParams(tenantId) },
)
.then((r) => r.data),
}

View File

@@ -0,0 +1,80 @@
/**
* Config Editor API client -- TypeScript functions for browsing RouterOS
* menu paths and executing commands on devices via the backend proxy.
*/
import { api } from './api'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BrowseResponse {
success: boolean
entries: Record<string, string>[]
error: string | null
path: string
}
export interface CommandResponse {
success: boolean
data: Record<string, string>[]
error: string | null
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
export const configEditorApi = {
browse: (tenantId: string, deviceId: string, path: string) =>
api
.get<BrowseResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/config-editor/browse`,
{ params: { path } },
)
.then((r) => r.data),
addEntry: (
tenantId: string,
deviceId: string,
path: string,
properties: Record<string, string>,
) =>
api
.post<CommandResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/config-editor/add`,
{ path, properties },
)
.then((r) => r.data),
setEntry: (
tenantId: string,
deviceId: string,
path: string,
entryId: string | undefined,
properties: Record<string, string>,
) =>
api
.post<CommandResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/config-editor/set`,
{ path, entry_id: entryId ?? null, properties },
)
.then((r) => r.data),
removeEntry: (tenantId: string, deviceId: string, path: string, entryId: string) =>
api
.post<CommandResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/config-editor/remove`,
{ path, entry_id: entryId },
)
.then((r) => r.data),
execute: (tenantId: string, deviceId: string, command: string) =>
api
.post<CommandResponse>(
`/api/tenants/${tenantId}/devices/${deviceId}/config-editor/execute`,
{ command },
)
.then((r) => r.data),
}

View File

@@ -0,0 +1,166 @@
/**
* Shared types and utilities for all config panel components.
*
* Every config panel (Interfaces, Firewall, DNS, DHCP, WiFi, Queues)
* imports these types to ensure a consistent change model and apply workflow.
*/
// ---------------------------------------------------------------------------
// Core Types
// ---------------------------------------------------------------------------
/** Apply mode: 'quick' (Standard Apply) executes add/set/remove directly; 'safe' (Safe Apply with auto-revert) generates an RSC script */
export type ApplyMode = 'quick' | 'safe'
/** A single pending configuration change to be previewed and applied */
export interface ConfigChange {
/** The operation to perform */
operation: 'add' | 'set' | 'remove'
/** RouterOS menu path, e.g. '/ip/address' */
path: string
/** Entry ID for set/remove operations (the RouterOS .id value) */
entryId?: string
/** Properties for add/set operations */
properties: Record<string, string>
/** Human-friendly description, e.g. "Add IP 192.168.1.1/24 to ether1" */
description: string
}
/** Standard props passed to every config panel tab component */
export interface ConfigPanelProps {
tenantId: string
deviceId: string
/** TanStack Query enabled guard -- only fetch data when the tab is active */
active: boolean
}
/** Field definition for dynamic form generation in config panels */
export interface PanelField {
/** RouterOS property name */
key: string
/** Human-friendly label */
label: string
/** Field type for form rendering */
type: 'text' | 'number' | 'boolean' | 'select'
/** Select options (only used when type='select') */
options?: string[]
/** Whether the field is required */
required?: boolean
/** Placeholder text */
placeholder?: string
/** Help text shown below the field */
help?: string
}
/** Function signature for RSC script generators */
export type RscScriptGenerator = (changes: ConfigChange[]) => string
// ---------------------------------------------------------------------------
// Default Apply Modes
// ---------------------------------------------------------------------------
/**
* Default apply mode per panel type.
*
* High-risk panels (interfaces, firewall) default to 'safe' mode.
* Lower-risk panels (dns, dhcp, wifi, queues) default to 'quick' mode.
*/
export const DEFAULT_APPLY_MODES: Record<string, ApplyMode> = {
interfaces: 'safe',
firewall: 'safe',
dns: 'quick',
dhcp: 'quick',
'dhcp-client': 'safe',
wifi: 'quick',
queues: 'quick',
// Phase 19: Routing & Addressing
routes: 'safe',
addresses: 'safe',
arp: 'quick',
pools: 'quick',
// Phase 20: System Configuration
system: 'safe',
users: 'safe',
services: 'safe',
scripts: 'quick',
// Phase 21: Advanced Firewall & Security
mangle: 'safe',
'address-lists': 'quick',
conntrack: 'safe',
// Phase 22: VPN & PPP Management
ppp: 'safe',
ipsec: 'safe',
// Phase 24: Bridge & VLAN Deep Config
'bridge-ports': 'safe',
'bridge-vlans': 'safe',
snmp: 'quick',
// Phase 27: Simple Configuration Interface
'simple-internet': 'safe',
'simple-lan': 'safe',
'simple-wifi': 'quick',
'simple-port-forwarding': 'safe',
'simple-firewall': 'safe',
'simple-dns': 'quick',
'simple-system': 'safe',
}
// ---------------------------------------------------------------------------
// RSC Script Generator
// ---------------------------------------------------------------------------
/**
* Converts an array of ConfigChange objects into a RouterOS RSC script string.
*
* Output example:
* /ip address add address=192.168.1.1/24 interface=ether1
* /ip firewall filter set [find where .id="*1"] disabled=yes
* /ip address remove [find where .id="*2"]
*/
export function generateRscScript(changes: ConfigChange[]): string {
return changes
.map((change) => {
const props = Object.entries(change.properties)
.map(([k, v]) => `${k}=${quoteIfNeeded(v)}`)
.join(' ')
switch (change.operation) {
case 'add':
return `${change.path} add ${props}`.trim()
case 'set': {
const selector = change.entryId
? `[find where .id="${change.entryId}"]`
: ''
return `${change.path} set ${selector} ${props}`.trim()
}
case 'remove': {
const selector = change.entryId
? `[find where .id="${change.entryId}"]`
: ''
return `${change.path} remove ${selector}`.trim()
}
}
})
.join('\n')
}
/**
* Quotes a value if it contains spaces or special characters.
*/
function quoteIfNeeded(value: string): string {
if (/[\s"\\]/.test(value)) {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
return value
}
// ---------------------------------------------------------------------------
// Change Description Generator
// ---------------------------------------------------------------------------
/**
* Converts an array of ConfigChange objects into human-readable descriptions.
* Used in the ChangePreviewModal for Standard Apply mode.
*/
export function describeChanges(changes: ConfigChange[]): string[] {
return changes.map((change, index) => `${index + 1}. ${change.description}`)
}

View File

@@ -0,0 +1,137 @@
/**
* Client-side data encryption module for tenant data.
*
* Encrypts/decrypts config backups, audit log details, and report content
* using the vault key (AES-256-GCM CryptoKey) already held in keyStore.
*
* Wire format: [12-byte nonce][ciphertext + 16-byte GCM tag]
* Transport encoding: base64 of the wire format for JSON payloads.
*
* SECURITY:
* - ALWAYS uses crypto.getRandomValues() (CSPRNG) for nonce generation
* - NEVER reuses nonces -- each encrypt call generates a fresh 12-byte random nonce
* - All CryptoKey objects are non-extractable (enforced at import time in keyStore)
* - No npm dependencies -- Web Crypto API only
*/
import type { EncryptedPayload } from './types';
const NONCE_BYTES = 12;
// ---- Base64 helpers (browser-native, no npm) ----
function uint8ToBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToUint8(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// ---- Core encryption / decryption ----
/**
* Encrypt arbitrary data using AES-256-GCM with the vault key.
* Generates a random 12-byte nonce per call (CSPRNG).
*/
export async function encryptForStorage(
data: Uint8Array,
vaultKey: CryptoKey,
): Promise<EncryptedPayload> {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce },
vaultKey,
data as BufferSource,
),
);
return { ciphertext, nonce };
}
/**
* Decrypt AES-256-GCM encrypted data using the vault key and nonce.
*/
export async function decryptFromStorage(
ciphertext: Uint8Array,
nonce: Uint8Array,
vaultKey: CryptoKey,
): Promise<Uint8Array> {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce as BufferSource },
vaultKey,
ciphertext as BufferSource,
);
return new Uint8Array(plaintext);
}
// ---- Pack / unpack for wire transport ----
/**
* Concatenate nonce (12 bytes) + ciphertext for transport.
* Format: [12-byte nonce][ciphertext + 16-byte GCM tag]
*/
export function packEncrypted(nonce: Uint8Array, ciphertext: Uint8Array): Uint8Array {
const packed = new Uint8Array(nonce.length + ciphertext.length);
packed.set(nonce, 0);
packed.set(ciphertext, nonce.length);
return packed;
}
/**
* Split packed format back into nonce and ciphertext.
*/
export function unpackEncrypted(packed: Uint8Array): { nonce: Uint8Array; ciphertext: Uint8Array } {
if (packed.length < NONCE_BYTES + 1) {
throw new Error(`Packed data too short: expected at least ${NONCE_BYTES + 1} bytes, got ${packed.length}`);
}
return {
nonce: packed.slice(0, NONCE_BYTES),
ciphertext: packed.slice(NONCE_BYTES),
};
}
// ---- Convenience: text ----
/**
* Encode text to UTF-8, encrypt, pack, and return as base64 string (for JSON transport).
*/
export async function encryptText(text: string, vaultKey: CryptoKey): Promise<string> {
const data = new TextEncoder().encode(text);
const { ciphertext, nonce } = await encryptForStorage(data, vaultKey);
return uint8ToBase64(packEncrypted(nonce, ciphertext));
}
/**
* Decode base64, unpack, decrypt, and return as UTF-8 string.
*/
export async function decryptText(base64Encrypted: string, vaultKey: CryptoKey): Promise<string> {
const packed = base64ToUint8(base64Encrypted);
const { nonce, ciphertext } = unpackEncrypted(packed);
const plaintext = await decryptFromStorage(ciphertext, nonce, vaultKey);
return new TextDecoder().decode(plaintext);
}
// ---- Convenience: binary ----
/**
* Encrypt binary data and return packed result as base64 string.
*/
export async function encryptBinary(data: Uint8Array, vaultKey: CryptoKey): Promise<string> {
const { ciphertext, nonce } = await encryptForStorage(data, vaultKey);
return uint8ToBase64(packEncrypted(nonce, ciphertext));
}
/**
* Decode base64, unpack, decrypt, and return raw bytes.
*/
export async function decryptBinary(base64Encrypted: string, vaultKey: CryptoKey): Promise<Uint8Array> {
const packed = base64ToUint8(base64Encrypted);
const { nonce, ciphertext } = unpackEncrypted(packed);
return decryptFromStorage(ciphertext, nonce, vaultKey);
}

View File

@@ -0,0 +1,123 @@
/**
* In-memory key lifecycle manager + IndexedDB for Secret Key persistence.
*
* SECURITY:
* - Session keys (AUK, vaultKey, privateKey) are module-scope variables, NEVER exported directly.
* - They are only accessible through the keyStore getter/setter functions.
* - On logout or tab close, clearAll() nullifies them for garbage collection.
* - CryptoKey objects are non-extractable — the browser enforces this.
* - IndexedDB stores ONLY the encrypted Secret Key (for returning-user convenience).
* - localStorage and sessionStorage are NEVER used for any key material.
*/
const DB_NAME = 'mikrotik-portal-keys';
const DB_VERSION = 1;
const STORE_NAME = 'secret-keys';
// Module-scope session keys — NEVER in state, localStorage, or sessionStorage
let _auk: CryptoKey | null = null;
let _vaultKey: CryptoKey | null = null;
let _privateKey: CryptoKey | null = null;
export const keyStore = {
// ---- Session key management (in-memory only) ----
setAUK(key: CryptoKey): void {
_auk = key;
},
getAUK(): CryptoKey | null {
return _auk;
},
setVaultKey(key: CryptoKey): void {
_vaultKey = key;
},
getVaultKey(): CryptoKey | null {
return _vaultKey;
},
setPrivateKey(key: CryptoKey): void {
_privateKey = key;
},
getPrivateKey(): CryptoKey | null {
return _privateKey;
},
/** Wipe all session keys from memory. Call on logout / tab close. */
clearAll(): void {
_auk = null;
_vaultKey = null;
_privateKey = null;
},
// ---- IndexedDB: encrypted Secret Key for returning users ----
/** Store an encrypted Secret Key blob for a given email address. */
async storeSecretKey(
email: string,
encryptedSecretKey: Uint8Array,
): Promise<void> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({
email: email.toLowerCase(),
data: encryptedSecretKey,
});
await txComplete(tx);
db.close();
},
/** Retrieve the encrypted Secret Key for a given email, or null if not found. */
async getSecretKey(email: string): Promise<Uint8Array | null> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get(email.toLowerCase());
const result = await new Promise<{ email: string; data: Uint8Array } | undefined>(
(resolve, reject) => {
request.onsuccess = () =>
resolve(request.result as { email: string; data: Uint8Array } | undefined);
request.onerror = () => reject(request.error);
},
);
db.close();
return result?.data ?? null;
},
/** Check whether an encrypted Secret Key exists for this email on this device. */
async hasSecretKey(email: string): Promise<boolean> {
const key = await this.getSecretKey(email);
return key !== null;
},
/** Remove the encrypted Secret Key for a given email. */
async deleteSecretKey(email: string): Promise<void> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(email.toLowerCase());
await txComplete(tx);
db.close();
},
};
// ---- Internal helpers ----
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'email' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function txComplete(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

View File

@@ -0,0 +1,236 @@
/**
* Two-Secret Key Derivation (2SKD) - 1Password-style key derivation chain.
*
* Derives two independent keys from Master Password + Secret Key:
* - AUK (Account Unlock Key): AES-256-GCM for data encryption, non-extractable
* - SRP-x: 32-byte value for SRP-6a authentication
*
* Derivation chain:
* 1. Stretch PBKDF2 salt via HKDF using email as context
* 2. PBKDF2-HMAC-SHA256 (650K iterations) on Master Password with stretched salt
* 3. HKDF on Secret Key using accountId as salt
* 4. XOR the two 32-byte results to produce the stretched key
* 5. HKDF-Expand from stretched key with info='auk' -> AUK bits
* 6. HKDF-Expand from stretched key with info='srp-x' -> SRP-x bits
*
* All cryptography uses the browser-native Web Crypto API. Zero npm dependencies.
*/
import type { DeriveKeysParams, DerivedKeys } from './types';
const DEFAULT_ITERATIONS = 650_000;
/**
* Core derivation logic shared by deriveKeys and deriveKeysRaw.
* Returns raw byte arrays for both AUK and SRP-x.
*/
async function deriveRawBytes(params: DeriveKeysParams): Promise<{ aukBits: ArrayBuffer; srpBits: ArrayBuffer }> {
const {
masterPassword,
secretKeyBytes,
email,
accountId,
pbkdf2Salt,
hkdfSalt: _hkdfSalt,
iterations = DEFAULT_ITERATIONS,
} = params;
// Suppress unused variable lint (hkdfSalt reserved for future use; PBKDF2 salt is
// stretched via HKDF inline below using the email as context)
void _hkdfSalt;
const encoder = new TextEncoder();
// Step 1: Stretch the PBKDF2 salt via HKDF using email as context
const hkdfSaltKey = await crypto.subtle.importKey(
'raw',
pbkdf2Salt.buffer.slice(pbkdf2Salt.byteOffset, pbkdf2Salt.byteOffset + pbkdf2Salt.byteLength) as ArrayBuffer,
'HKDF',
false,
['deriveBits'],
);
const stretchedSalt = new Uint8Array(
await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode(email.toLowerCase()),
info: encoder.encode('srp'),
},
hkdfSaltKey,
256,
),
);
// Step 2: PBKDF2 with 650K iterations on Master Password
const passwordKey = await crypto.subtle.importKey(
'raw',
encoder.encode(masterPassword),
'PBKDF2',
false,
['deriveBits'],
);
const pbkdf2Result = new Uint8Array(
await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
hash: 'SHA-256',
salt: stretchedSalt,
iterations,
},
passwordKey,
256,
),
);
// Step 3: HKDF on Secret Key using accountId as salt
const skKey = await crypto.subtle.importKey(
'raw',
secretKeyBytes.buffer.slice(secretKeyBytes.byteOffset, secretKeyBytes.byteOffset + secretKeyBytes.byteLength) as ArrayBuffer,
'HKDF',
false,
['deriveBits'],
);
const skDerived = new Uint8Array(
await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode(accountId),
info: encoder.encode('auk'),
},
skKey,
256,
),
);
// Step 4: XOR the two 32-byte values to produce the stretched key
const stretched = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
stretched[i] = pbkdf2Result[i]! ^ skDerived[i]!;
}
// Step 5: Import stretched key for HKDF-Expand
const stretchedKey = await crypto.subtle.importKey(
'raw',
stretched,
'HKDF',
false,
['deriveBits'],
);
// Step 6: Derive AUK via HKDF-Expand (info='auk')
const aukBits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(0),
info: encoder.encode('auk'),
},
stretchedKey,
256,
);
// Step 7: Derive SRP-x via HKDF-Expand (info='srp-x') - INDEPENDENT from AUK
const srpBits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(0),
info: encoder.encode('srp-x'),
},
stretchedKey,
256,
);
return { aukBits, srpBits };
}
/**
* Derive AUK (as non-extractable CryptoKey) and SRP-x from Master Password + Secret Key.
* Runs on whichever thread calls it (main thread or Web Worker).
*/
export async function deriveKeys(params: DeriveKeysParams): Promise<DerivedKeys> {
const { aukBits, srpBits } = await deriveRawBytes(params);
// Import AUK as non-extractable AES-256-GCM key
const auk = await crypto.subtle.importKey(
'raw',
aukBits,
{ name: 'AES-GCM', length: 256 },
false, // CRITICAL: non-extractable
['wrapKey', 'unwrapKey', 'encrypt', 'decrypt'],
);
return { auk, srpX: new Uint8Array(srpBits) };
}
/**
* Derive raw AUK bytes and SRP-x bytes (for Web Worker which cannot transfer CryptoKey).
* The main thread re-imports the raw AUK bytes as a non-extractable CryptoKey.
*/
export async function deriveKeysRaw(
params: DeriveKeysParams,
): Promise<{ aukRaw: Uint8Array; srpX: Uint8Array }> {
const { aukBits, srpBits } = await deriveRawBytes(params);
return {
aukRaw: new Uint8Array(aukBits),
srpX: new Uint8Array(srpBits),
};
}
/**
* Derive keys in a Web Worker to avoid blocking the UI thread.
* The Worker computes raw bytes, then the main thread imports AUK as non-extractable CryptoKey.
*
* CryptoKey objects cannot be transferred via postMessage when non-extractable,
* so the Worker returns raw bytes which we re-import here.
*/
export function deriveKeysInWorker(params: DeriveKeysParams): Promise<DerivedKeys> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' },
);
worker.onmessage = async (e: MessageEvent) => {
const response = e.data as { type: string; aukRaw?: number[]; srpX?: number[]; error?: string };
if (response.type === 'keysReady' && response.aukRaw && response.srpX) {
try {
// Re-import raw AUK bytes as non-extractable CryptoKey on main thread
const auk = await crypto.subtle.importKey(
'raw',
new Uint8Array(response.aukRaw),
{ name: 'AES-GCM', length: 256 },
false, // CRITICAL: non-extractable
['wrapKey', 'unwrapKey', 'encrypt', 'decrypt'],
);
resolve({ auk, srpX: new Uint8Array(response.srpX) });
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
} else {
reject(new Error(response.error ?? 'Worker key derivation failed'));
}
worker.terminate();
};
worker.onerror = (e) => {
reject(new Error(`Worker error: ${e.message}`));
worker.terminate();
};
worker.postMessage({
type: 'deriveKeys',
payload: {
masterPassword: params.masterPassword,
secretKeyBytes: Array.from(params.secretKeyBytes),
email: params.email,
accountId: params.accountId,
pbkdf2Salt: Array.from(params.pbkdf2Salt),
hkdfSalt: Array.from(params.hkdfSalt),
iterations: params.iterations ?? DEFAULT_ITERATIONS,
},
});
});
}

View File

@@ -0,0 +1,170 @@
/**
* Client-side registration flow for zero-knowledge SRP authentication.
*
* Generates all cryptographic material locally:
* 1. Secret Key (128-bit CSPRNG, never sent to server)
* 2. SRP salt + verifier (derived from 2SKD chain)
* 3. RSA-2048 keypair (private key wrapped with AUK)
* 4. Tenant vault key (wrapped with AUK)
*
* The Secret Key is returned to the caller for display in the Emergency Kit dialog.
* It is NEVER included in any server request.
*/
import { generateSecretKey } from './secretKey';
import { deriveKeysInWorker } from './keys';
import { computeVerifier } from './srp';
/**
* Check if the Web Crypto API is available (requires secure context: HTTPS or localhost).
* Throws a user-friendly error if not.
*/
export function assertWebCryptoAvailable(): void {
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new Error(
'Your browser requires a secure connection (HTTPS) for encryption features. ' +
'Please access this application via HTTPS or localhost.',
);
}
}
export interface RegistrationResult {
/** Formatted Secret Key (A3-XXXXXX-...) for display in Emergency Kit */
secretKey: string;
/** Raw Secret Key bytes for IndexedDB storage */
secretKeyRaw: Uint8Array;
/** SRP registration data to send to server */
srpRegistration: {
srp_salt: string; // hex
srp_verifier: string; // hex
};
/** Encrypted key bundle to send to server */
keyBundle: {
encrypted_private_key: string; // base64
private_key_nonce: string; // base64
encrypted_vault_key: string; // base64
vault_key_nonce: string; // base64
public_key: string; // base64
pbkdf2_salt: string; // base64
hkdf_salt: string; // base64
};
}
/** Convert an ArrayBuffer or Uint8Array to a base64 string. */
function toBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary);
}
/** Convert a Uint8Array to a lowercase hex string. */
function toHex(bytes: Uint8Array): string {
let hex = '';
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i]!.toString(16).padStart(2, '0');
}
return hex;
}
/**
* Perform full client-side registration: generate Secret Key, derive SRP
* credentials, create and wrap RSA keypair and vault key.
*
* @param email - User's email address
* @param masterPassword - User's chosen master password
* @returns Registration result with Secret Key (for display) and server payloads
*/
export async function performRegistration(
email: string,
masterPassword: string,
): Promise<RegistrationResult> {
// 0. Verify Web Crypto API is available (requires HTTPS or localhost)
assertWebCryptoAvailable();
// 1. Generate Secret Key (128-bit, client-only)
const { formatted: secretKey, raw: secretKeyRaw } = generateSecretKey();
// 2. Generate random salts
const pbkdf2Salt = crypto.getRandomValues(new Uint8Array(32));
const hkdfSalt = crypto.getRandomValues(new Uint8Array(32));
// 3. Derive AUK + SRP-x via Web Worker (avoids blocking UI)
const { auk, srpX } = await deriveKeysInWorker({
masterPassword,
secretKeyBytes: secretKeyRaw,
email,
accountId: email,
pbkdf2Salt,
hkdfSalt,
});
// 4. Generate SRP salt and compute verifier
const srpSalt = crypto.getRandomValues(new Uint8Array(32));
const srpSaltHex = toHex(srpSalt);
const srpXHex = toHex(srpX);
// Compute verifier: v = g^x mod N
const verifierHex = computeVerifier(srpXHex);
// 5. Generate RSA-2048 keypair
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true, // extractable=true needed for wrapKey export
['encrypt', 'decrypt'],
);
// 6. Export public key (SPKI format)
const publicKeyBuffer = await crypto.subtle.exportKey('spki', keyPair.publicKey);
// 7. Wrap private key with AUK (AES-GCM)
const privateKeyNonce = crypto.getRandomValues(new Uint8Array(12));
const wrappedPrivateKey = await crypto.subtle.wrapKey(
'pkcs8',
keyPair.privateKey,
auk,
{ name: 'AES-GCM', iv: privateKeyNonce },
);
// 8. Generate tenant vault key (AES-256-GCM)
// For new users, generate a random vault key.
// In a multi-user tenant, this would be the existing tenant vault key
// encrypted with this user's public key. For now, generate fresh.
const vaultKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable for wrapping
['encrypt', 'decrypt'],
);
// 9. Wrap vault key with AUK (AES-GCM)
const vaultKeyNonce = crypto.getRandomValues(new Uint8Array(12));
const wrappedVaultKey = await crypto.subtle.wrapKey('raw', vaultKey, auk, {
name: 'AES-GCM',
iv: vaultKeyNonce,
});
// 10. Base64 encode all binary data for transport
return {
secretKey,
secretKeyRaw,
srpRegistration: {
srp_salt: srpSaltHex,
srp_verifier: verifierHex,
},
keyBundle: {
encrypted_private_key: toBase64(wrappedPrivateKey),
private_key_nonce: toBase64(privateKeyNonce),
encrypted_vault_key: toBase64(wrappedVaultKey),
vault_key_nonce: toBase64(vaultKeyNonce),
public_key: toBase64(publicKeyBuffer),
pbkdf2_salt: toBase64(pbkdf2Salt),
hkdf_salt: toBase64(hkdfSalt),
},
};
}

View File

@@ -0,0 +1,83 @@
/**
* Secret Key generation and parsing.
*
* The Secret Key is a 128-bit CSPRNG value formatted as A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX
* using a 30-character alphabet (ambiguous characters removed). It is generated client-side
* and NEVER transmitted to the server.
*
* Encoding: 16 bytes (128 bits) -> BigInt -> base-30 -> 26 characters -> grouped with hyphens.
*/
// Uppercase letters minus O, I, L, S (ambiguous) + digits minus 0, 1
// = 22 letters + 8 digits = 30 characters
const CHARSET = 'ABCDEFGHJKMNPQRTUVWXYZ23456789';
const BASE = BigInt(CHARSET.length); // 30n
const KEY_CHAR_LENGTH = 26;
const RAW_BYTE_LENGTH = 16;
/**
* Generate a new Secret Key with 128 bits of entropy.
* Returns both the formatted string (for display) and the raw bytes (for derivation).
*/
export function generateSecretKey(): { formatted: string; raw: Uint8Array } {
const raw = new Uint8Array(RAW_BYTE_LENGTH);
crypto.getRandomValues(raw);
const formatted = formatSecretKey(raw);
return { formatted, raw };
}
/**
* Encode 16 raw bytes into the A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX format.
*/
export function formatSecretKey(raw: Uint8Array): string {
// Convert 16 bytes to a BigInt (big-endian)
let n = 0n;
for (const byte of raw) {
n = (n << 8n) | BigInt(byte);
}
// Base-30 encode to 26 characters (ceil(128 / log2(30)) ~= 26.1)
const chars: string[] = [];
for (let i = 0; i < KEY_CHAR_LENGTH; i++) {
chars.push(CHARSET[Number(n % BASE)]);
n = n / BASE;
}
// Format: A3-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX
const keyStr = chars.join('');
const groups: string[] = [];
for (let i = 0; i < keyStr.length; i += 6) {
groups.push(keyStr.slice(i, i + 6));
}
return `A3-${groups.join('-')}`;
}
/**
* Parse a formatted Secret Key back to 16 raw bytes.
* Returns null if the input is invalid.
*/
export function parseSecretKey(input: string): Uint8Array | null {
// Strip hyphens, spaces, and normalize to uppercase
const cleaned = input.replace(/-/g, '').replace(/\s/g, '').toUpperCase();
if (!cleaned.startsWith('A3')) return null;
const keyPart = cleaned.slice(2);
if (keyPart.length < KEY_CHAR_LENGTH) return null;
// Reverse base-30 encoding: reconstruct the BigInt
// chars were pushed least-significant first, so index 0 is the lowest digit
let n = 0n;
for (let i = keyPart.length - 1; i >= 0; i--) {
const idx = CHARSET.indexOf(keyPart[i]);
if (idx === -1) return null;
n = n * BASE + BigInt(idx);
}
// Convert BigInt to 16 bytes (big-endian)
const bytes = new Uint8Array(RAW_BYTE_LENGTH);
for (let i = RAW_BYTE_LENGTH - 1; i >= 0; i--) {
bytes[i] = Number(n & 0xFFn);
n = n >> 8n;
}
return bytes;
}

View File

@@ -0,0 +1,331 @@
/**
* Custom SRP-6a client implementation using native BigInt + Web Crypto API.
*
* Designed for interop with srptools (Python) server-side library.
* Uses RFC 5054 2048-bit group parameters with SHA-256 hash.
*
* Key conventions for srptools interop:
* - All BigNum values are lowercase hex strings (no '0x' prefix)
* - Pad BigInt values to N's byte length (256 bytes / 512 hex chars) for hashing
* - M1 = H(H(N) XOR H(g) | H(I) | s | A | B | K)
* - M2 = H(A | M1 | K)
*
* Zero npm dependencies - uses only native BigInt and crypto.subtle.digest.
*/
// ---- RFC 5054 2048-bit Group Parameters ----
// RFC 5054 Appendix A, 2048-bit safe prime (lowercase hex)
const N_HEX =
'ac6bdb41324a9a9bf166de5e1389582faf72b6651987ee07fc3192943db56050' +
'a37329cbb4a099ed8193e0757767a13dd52312ab4b03310dcd7f48a9da04fd50' +
'e8083969edb767b0cf6095179a163ab3661a05fbd5faaae82918a9962f0b93b8' +
'55f97993ec975eeaa80d740adbf4ff747359d041d5c33ea71d281e446b14773b' +
'ca97b43a23fb801676bd207a436c6481f1d2b9078717461a5b9d32e688f87748' +
'544523b524b0d57d5ea77a2775d2ecfa032cfbdbf52fb3786160279004e57ae6' +
'af874e7303ce53299ccc041c7bc308d82a5698f3a8d0c38271ae35f8e9dbfbb6' +
'94b5c803d89f7ae435de236d525f54759b65e372fcd68ef20fa7111f9e4aff73';
const N = BigInt('0x' + N_HEX);
const g = 2n;
const N_BYTES = 256; // 2048 bits = 256 bytes
const N_HEX_LEN = N_BYTES * 2; // 512 hex chars
// ---- Utility Functions ----
/** Convert BigInt to lowercase hex string (no prefix, no padding). */
function toHex(n: bigint): string {
const hex = n.toString(16);
return hex;
}
/** Pad a hex string to N's byte length (512 hex chars) with leading zeros. */
function padHex(hex: string): string {
return hex.padStart(N_HEX_LEN, '0');
}
/** Convert BigInt to padded hex bytes (for hash inputs involving N-sized values). */
function bigintToPaddedHex(n: bigint): string {
return padHex(toHex(n));
}
/** Convert hex string to Uint8Array. */
function hexToBytes(hex: string): Uint8Array {
const padded = hex.length % 2 === 1 ? '0' + hex : hex;
const bytes = new Uint8Array(padded.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
/** Convert Uint8Array to lowercase hex string. */
function bytesToHex(bytes: Uint8Array): string {
let hex = '';
for (const b of bytes) {
hex += b.toString(16).padStart(2, '0');
}
return hex;
}
/** SHA-256 hash of concatenated byte arrays. */
async function H(...inputs: Uint8Array[]): Promise<Uint8Array> {
// Calculate total length
let totalLength = 0;
for (const input of inputs) {
totalLength += input.length;
}
// Concatenate
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const input of inputs) {
combined.set(input, offset);
offset += input.length;
}
const digest = await crypto.subtle.digest('SHA-256', combined);
return new Uint8Array(digest);
}
/** Convert BigInt to minimal-length bytes (matches srptools int_to_bytes). */
function bigintToBytes(n: bigint): Uint8Array {
return hexToBytes(toHex(n));
}
/** Hash BigInt values (unpadded, matching srptools int_to_bytes) and return bytes. */
async function hashBigInt(...values: bigint[]): Promise<Uint8Array> {
const inputs = values.map((v) => bigintToBytes(v));
return H(...inputs);
}
/** Pad a BigInt value to N's byte length (256 bytes) matching srptools context.pad(). */
function padBigInt(n: bigint): Uint8Array {
const bytes = bigintToBytes(n);
if (bytes.length >= N_BYTES) return bytes;
const padded = new Uint8Array(N_BYTES);
padded.set(bytes, N_BYTES - bytes.length);
return padded;
}
/**
* Modular exponentiation: base^exp mod mod.
*
* Uses Montgomery ladder (constant number of multiplications per bit)
* for timing resistance against side-channel attacks.
*/
function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
if (mod === 0n) throw new Error('modPow: modulus cannot be zero');
if (mod === 1n) return 0n;
base = ((base % mod) + mod) % mod;
// Montgomery ladder: constant-time per bit
let r0 = 1n;
let r1 = base;
const bits = exp.toString(2);
for (let i = 0; i < bits.length; i++) {
if (bits[i] === '1') {
r0 = (r0 * r1) % mod;
r1 = (r1 * r1) % mod;
} else {
r1 = (r0 * r1) % mod;
r0 = (r0 * r0) % mod;
}
}
return r0;
}
/** Generate 256 bits of cryptographic randomness as a BigInt. */
function generateRandomBigInt(): bigint {
const bytes = new Uint8Array(32); // 256 bits
crypto.getRandomValues(bytes);
let n = 0n;
for (const b of bytes) {
n = (n << 8n) | BigInt(b);
}
return n;
}
// ---- SRP Client ----
/**
* SRP-6a client for zero-knowledge authentication.
*
* Usage:
* 1. const client = new SRPClient(email);
* 2. Send client.getPublicEphemeral() to server in /auth/srp/init
* 3. Receive server's B and salt
* 4. const { clientProof, sessionKey } = await client.computeSession(srpX, salt, B)
* 5. Send clientProof to server in /auth/srp/verify
* 6. Receive server proof M2
* 7. await client.verifyServerProof(M2)
*/
export class SRPClient {
private readonly _N = N;
private readonly _g = g;
private readonly _a: bigint; // Client private ephemeral
private readonly _A: bigint; // Client public ephemeral = g^a mod N
private readonly _email: string;
// Set during computeSession, used by verifyServerProof
private _A_hex = '';
private _M1: Uint8Array | null = null;
private _K: Uint8Array | null = null;
constructor(email: string) {
this._email = email;
// Generate random private ephemeral a (256 bits)
this._a = generateRandomBigInt();
// Compute public ephemeral A = g^a mod N
this._A = modPow(this._g, this._a, this._N);
// SRP spec: if A % N == 0, abort (astronomically unlikely with random a)
if (this._A % this._N === 0n) {
throw new Error('SRP: invalid client public ephemeral (A mod N == 0)');
}
}
/** Get the hex-encoded client public ephemeral A to send to the server. */
getPublicEphemeral(): string {
const hex = toHex(this._A);
// Ensure even-length hex for server compatibility
return hex.length % 2 === 1 ? '0' + hex : hex;
}
/**
* Compute session key and client proof from server parameters.
*
* @param srpX - 32-byte SRP-x from 2SKD key derivation
* @param saltHex - Server-provided SRP salt (hex)
* @param serverPublicHex - Server-provided public ephemeral B (hex)
* @returns Client proof M1 (hex) and session key K (32 bytes)
*/
async computeSession(
srpX: Uint8Array,
saltHex: string,
serverPublicHex: string,
): Promise<{ clientProof: string; sessionKey: Uint8Array }> {
const B = BigInt('0x' + serverPublicHex);
// SRP spec: if B % N == 0, abort
if (B % this._N === 0n) {
throw new Error('SRP: invalid server public ephemeral (B mod N == 0)');
}
// Compute k = H(N | PAD(g)) — srptools pads g to N's byte length
const kHash = await H(bigintToBytes(this._N), padBigInt(this._g));
const k = BigInt('0x' + bytesToHex(kHash));
// Compute u = H(PAD(A) | PAD(B)) — srptools pads both to N's byte length
const uHash = await H(padBigInt(this._A), padBigInt(B));
const u = BigInt('0x' + bytesToHex(uHash));
// SRP spec: if u == 0, abort
if (u === 0n) {
throw new Error('SRP: invalid scrambling parameter (u == 0)');
}
// Convert SRP-x bytes to BigInt
const x = BigInt('0x' + bytesToHex(srpX));
// Compute S = (B - k * g^x mod N)^(a + u*x) mod N
const gx = modPow(this._g, x, this._N);
const kgx = (k * gx) % this._N;
// Ensure (B - kgx) is positive by adding N
const base = ((B - kgx) % this._N + this._N) % this._N;
const exp = (this._a + u * x) % (this._N - 1n); // Exponent mod (N-1) by Fermat's little theorem extension
const S = modPow(base, exp, this._N);
// Compute session key K = H(S) — unpadded, matching srptools
const K = await H(bigintToBytes(S));
this._K = K;
// Compute M1 = H(H(N) XOR H(g) | H(I) | s | A | B | K)
// All BigInt values use unpadded encoding to match srptools convention
const hN = await H(bigintToBytes(this._N));
const hg = await H(bigintToBytes(this._g));
// XOR H(N) and H(g)
const hNxorHg = new Uint8Array(hN.length);
for (let i = 0; i < hN.length; i++) {
hNxorHg[i] = hN[i]! ^ hg[i]!;
}
// H(I) = H(email)
const hI = await H(new TextEncoder().encode(this._email));
// Salt as bytes
const saltBytes = hexToBytes(saltHex);
// A and B as unpadded bytes (matching srptools int_to_bytes)
const aHex = toHex(this._A);
this._A_hex = aHex;
const bHex = toHex(B);
const M1 = await H(
hNxorHg,
hI,
saltBytes,
hexToBytes(aHex),
hexToBytes(bHex),
K,
);
this._M1 = M1;
return {
clientProof: bytesToHex(M1),
sessionKey: K,
};
}
/**
* Verify the server's proof M2.
* Must be called after computeSession().
*
* @param serverProofHex - Server-provided M2 proof (hex)
* @returns true if server proof is valid
*/
async verifyServerProof(serverProofHex: string): Promise<boolean> {
if (!this._M1 || !this._K) {
throw new Error('SRP: computeSession() must be called before verifyServerProof()');
}
// M2 = H(A | M1 | K)
const expectedM2 = await H(
hexToBytes(this._A_hex),
this._M1,
this._K,
);
const expectedHex = bytesToHex(expectedM2);
const actualHex = serverProofHex.toLowerCase();
// Constant-time comparison (to the extent possible in JS)
if (expectedHex.length !== actualHex.length) return false;
let diff = 0;
for (let i = 0; i < expectedHex.length; i++) {
diff |= expectedHex.charCodeAt(i) ^ actualHex.charCodeAt(i);
}
return diff === 0;
}
}
/**
* Compute SRP verifier v = g^x mod N.
* Used during registration to create the verifier stored on the server.
*
* @param srpXHex - Hex-encoded SRP-x from 2SKD key derivation
* @returns Hex-encoded verifier v (even-length, suitable for Python bytes.fromhex)
*/
export function computeVerifier(srpXHex: string): string {
const x = BigInt('0x' + srpXHex);
const v = modPow(g, x, N);
const hex = toHex(v);
// Ensure even-length hex for Python bytes.fromhex() compatibility
return hex.length % 2 === 1 ? '0' + hex : hex;
}

View File

@@ -0,0 +1,70 @@
/**
* TypeScript interfaces for the zero-knowledge crypto module.
*
* These types define the contracts between key derivation, SRP authentication,
* the Web Worker, and the key store.
*/
/** Parameters for the 2SKD (Two-Secret Key Derivation) chain. */
export interface DeriveKeysParams {
masterPassword: string;
secretKeyBytes: Uint8Array; // 16 bytes (128 bits) raw
email: string;
accountId: string; // tenant UUID or user UUID for super_admin
pbkdf2Salt: Uint8Array; // 32 bytes from server
hkdfSalt: Uint8Array; // 32 bytes from server
iterations?: number; // default 650000
}
/** Result of key derivation — AUK for encryption, SRP-x for authentication. */
export interface DerivedKeys {
auk: CryptoKey; // AES-256-GCM, non-extractable
srpX: Uint8Array; // 32 bytes for SRP verifier computation
}
/** Encrypted key bundle stored server-side during registration. */
export interface KeyBundle {
encryptedPrivateKey: ArrayBuffer;
privateKeyNonce: Uint8Array; // 12 bytes
encryptedVaultKey: ArrayBuffer;
vaultKeyNonce: Uint8Array; // 12 bytes
publicKey: ArrayBuffer; // RSA-2048 SPKI format
pbkdf2Salt: Uint8Array; // 32 bytes
hkdfSalt: Uint8Array; // 32 bytes
pbkdf2Iterations: number;
}
/** Message sent from main thread to crypto Web Worker. */
export interface WorkerMessage {
type: 'deriveKeys';
payload: {
masterPassword: string;
secretKeyBytes: number[]; // Uint8Array serialized as number[] for postMessage
email: string;
accountId: string;
pbkdf2Salt: number[];
hkdfSalt: number[];
iterations: number;
};
}
/** Response from crypto Web Worker back to main thread. */
export interface WorkerResponse {
type: 'keysReady' | 'error';
aukRaw?: number[]; // Raw AUK bytes for reimport on main thread
srpX?: number[]; // Raw SRP-x bytes
error?: string;
}
/** Result of client-side AES-256-GCM encryption. */
export interface EncryptedPayload {
ciphertext: Uint8Array;
nonce: Uint8Array; // 12 bytes
}
/** Payload shape for encrypted config backup transport (JSON-friendly). */
export interface EncryptedBackupPayload {
encrypted_export: string; // base64 of packed nonce+ciphertext
encrypted_binary: string; // base64 of packed nonce+ciphertext
encryption_tier: 1;
}

View File

@@ -0,0 +1,52 @@
/**
* Web Worker entry point for PBKDF2/HKDF key derivation.
*
* Runs the expensive 2SKD computation (650K PBKDF2 iterations) off the main thread
* to prevent UI freezing during login. The Worker returns raw byte arrays which the
* main thread re-imports as non-extractable CryptoKey objects.
*
* CryptoKey objects cannot be transferred via postMessage when non-extractable,
* so we return raw bytes (serialized as number[]) instead.
*/
import { deriveKeysRaw } from './keys';
self.onmessage = async (event: MessageEvent) => {
const { type, payload } = event.data as {
type: string;
payload: {
masterPassword: string;
secretKeyBytes: number[];
email: string;
accountId: string;
pbkdf2Salt: number[];
hkdfSalt: number[];
iterations: number;
};
};
if (type === 'deriveKeys') {
try {
const { aukRaw, srpX } = await deriveKeysRaw({
masterPassword: payload.masterPassword,
secretKeyBytes: new Uint8Array(payload.secretKeyBytes),
email: payload.email,
accountId: payload.accountId,
pbkdf2Salt: new Uint8Array(payload.pbkdf2Salt),
hkdfSalt: new Uint8Array(payload.hkdfSalt),
iterations: payload.iterations,
});
self.postMessage({
type: 'keysReady',
aukRaw: Array.from(aukRaw),
srpX: Array.from(srpX),
});
} catch (err) {
self.postMessage({
type: 'error',
error: err instanceof Error ? err.message : String(err),
});
}
}
};

View File

@@ -0,0 +1,117 @@
/**
* Client-side text diff computation utilities.
*
* Used for computing diffs on encrypted config backups where the server
* cannot see plaintext. Handles mixed encryption tiers:
* - Tier 1: Client-side encrypted -- decrypt with vault key, then diff
* - Tier 2: Transit encrypted -- server decrypts for us, plaintext arrives
* - NULL: Legacy plaintext -- use as-is
*
* The existing ConfigDiffViewer uses @git-diff-view/react which takes raw
* oldText/newText strings. These utilities provide the decrypted text strings
* for that component, plus a line-based diff for summary counts.
*/
import { diffLines } from 'diff';
import { decryptText } from './crypto/dataEncryption';
/** A single diff change (matches the `Change` type from the `diff` package). */
export interface DiffResult {
value: string;
added?: boolean;
removed?: boolean;
count?: number;
}
/**
* Compute a line-based diff between two plaintext strings.
* Returns an array of DiffResult objects compatible with the `diff` package's Change type.
*/
export function computeConfigDiff(oldText: string, newText: string): DiffResult[] {
return diffLines(oldText, newText);
}
/**
* Decrypt (if needed) and compute a diff between two config versions.
*
* Handles mixed encryption tiers:
* - Tier 1 (client-side encrypted): decrypt with vault key first
* - Tier 2 (transit encrypted): server already sent plaintext
* - NULL (legacy plaintext): use as-is
*
* @param oldEncrypted - Old config text (may be base64-encrypted for Tier 1)
* @param oldTier - Encryption tier of old version (1, 2, or null)
* @param newEncrypted - New config text (may be base64-encrypted for Tier 1)
* @param newTier - Encryption tier of new version (1, 2, or null)
* @param vaultKey - AES-256-GCM vault key for Tier 1 decryption
*/
export async function computeEncryptedConfigDiff(
oldEncrypted: string,
oldTier: number | null,
newEncrypted: string,
newTier: number | null,
vaultKey: CryptoKey,
): Promise<DiffResult[]> {
const oldText = await decryptByTier(oldEncrypted, oldTier, vaultKey);
const newText = await decryptByTier(newEncrypted, newTier, vaultKey);
return computeConfigDiff(oldText, newText);
}
/**
* Decrypt text based on encryption tier and return plaintext for diff.
*
* @param oldEncrypted - Old config text (may be base64-encrypted for Tier 1)
* @param oldTier - Encryption tier (1, 2, or null)
* @param newEncrypted - New config text (may be base64-encrypted for Tier 1)
* @param newTier - Encryption tier (1, 2, or null)
* @param vaultKey - AES-256-GCM vault key for Tier 1 decryption
* @returns Object with decrypted oldText and newText strings
*/
export async function decryptForDiff(
oldEncrypted: string,
oldTier: number | null,
newEncrypted: string,
newTier: number | null,
vaultKey: CryptoKey,
): Promise<{ oldText: string; newText: string }> {
const oldText = await decryptByTier(oldEncrypted, oldTier, vaultKey);
const newText = await decryptByTier(newEncrypted, newTier, vaultKey);
return { oldText, newText };
}
/**
* Count added and removed lines from a diff result array.
* Replaces server-side compute_line_delta for Tier 1 backups
* where the server cannot see plaintext.
*/
export function computeLineCounts(diffs: DiffResult[]): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const diff of diffs) {
const lineCount = diff.count ?? diff.value.split('\n').filter(Boolean).length;
if (diff.added) {
added += lineCount;
} else if (diff.removed) {
removed += lineCount;
}
}
return { added, removed };
}
// ---- Internal ----
/**
* Decrypt a single text value based on its encryption tier.
*/
async function decryptByTier(
text: string,
tier: number | null,
vaultKey: CryptoKey,
): Promise<string> {
if (tier === 1) {
// Tier 1: Client-side encrypted -- decrypt with vault key
return decryptText(text, vaultKey);
}
// Tier 2 or NULL: plaintext (server decrypted or never encrypted)
return text;
}

View File

@@ -0,0 +1,58 @@
import { AxiosError } from 'axios'
/**
* Extract a human-readable error message from any error type.
* Priority: API detail > Axios status mapping > Error.message > fallback
*/
export function getErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
if (error instanceof AxiosError) {
const detail = error.response?.data?.detail
if (typeof detail === 'string') return detail
switch (error.response?.status) {
case 400: return 'Invalid request. Please check your input.'
case 401: return 'Your session has expired. Please sign in again.'
case 403: return 'You do not have permission for this action.'
case 404: return 'The requested resource was not found.'
case 409: return detail || 'This action conflicts with the current state.'
case 422: return 'Please check your input and try again.'
case 429: return 'Too many requests. Please wait a moment and try again.'
case 500: return 'Something went wrong on our end. Please try again.'
case 502:
case 503: return 'The service is temporarily unavailable. Please try again later.'
default: return fallback
}
}
if (error instanceof Error) {
if (error.message.startsWith('Request failed with status code')) {
return fallback
}
return error.message
}
return fallback
}
/**
* Extract error message specifically for auth flows.
*/
export function getAuthErrorMessage(error: unknown): string {
if (error instanceof AxiosError) {
const detail = error.response?.data?.detail
if (typeof detail === 'string') return detail
switch (error.response?.status) {
case 400: return 'Sign in failed. Please check your credentials.'
case 401: return 'Invalid email or password.'
case 500: return 'Something went wrong during sign in. Please try again.'
default: return 'Sign in failed. Please try again.'
}
}
if (error instanceof Error) {
return error.message
}
return 'Sign in failed. Please try again.'
}

View File

@@ -0,0 +1,24 @@
import { api } from './api'
export interface DashboardEvent {
id: string
event_type: 'alert' | 'status_change' | 'config_backup'
severity: 'critical' | 'warning' | 'info'
title: string
description: string
device_hostname: string | null
device_id: string | null
timestamp: string // ISO 8601
}
export interface EventsParams {
limit?: number
event_type?: 'alert' | 'status_change' | 'config_backup'
}
export const eventsApi = {
getEvents: (tenantId: string, params?: EventsParams) =>
api
.get<DashboardEvent[]>(`/api/tenants/${tenantId}/events`, { params })
.then((r) => r.data),
}

View File

@@ -0,0 +1,207 @@
/**
* Firmware API client — TypeScript functions for firmware overview,
* version management, upgrade orchestration, and scheduling.
*/
import { api } from './api'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DeviceFirmwareStatus {
id: string
hostname: string
ip_address: string
routeros_version: string | null
architecture: string | null
latest_version: string | null
channel: string
is_up_to_date: boolean
serial_number: string | null
firmware_version: string | null
model: string | null
}
export interface FirmwareVersionGroup {
version: string
count: number
is_latest: boolean
devices: DeviceFirmwareStatus[]
}
export interface FirmwareOverview {
devices: DeviceFirmwareStatus[]
version_groups: FirmwareVersionGroup[]
summary: { total: number; up_to_date: number; outdated: number; unknown: number }
}
export interface FirmwareUpgradeJob {
id: string
device_id: string
device_hostname?: string
rollout_group_id: string | null
target_version: string
architecture: string
channel: string
status:
| 'pending'
| 'scheduled'
| 'downloading'
| 'uploading'
| 'rebooting'
| 'verifying'
| 'completed'
| 'failed'
| 'paused'
pre_upgrade_backup_sha: string | null
scheduled_at: string | null
started_at: string | null
completed_at: string | null
error_message: string | null
confirmed_major_upgrade: boolean
created_at: string
}
export interface UpgradeJobsResponse {
items: FirmwareUpgradeJob[]
total: number
page: number
per_page: number
}
export interface RolloutStatus {
rollout_group_id: string
total: number
completed: number
failed: number
paused: number
pending: number
current_device: string | null
jobs: FirmwareUpgradeJob[]
}
export interface FirmwareVersion {
id: string
architecture: string
channel: string
version: string
npk_url: string
npk_local_path: string | null
npk_size_bytes: number | null
checked_at: string | null
}
export interface UpgradeRequestData {
device_id: string
target_version: string
architecture: string
channel?: string
confirmed_major_upgrade?: boolean
scheduled_at?: string | null
}
export interface MassUpgradeRequestData {
device_ids: string[]
target_version: string
channel?: string
confirmed_major_upgrade?: boolean
scheduled_at?: string | null
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
export const firmwareApi = {
// -- Overview --
getFirmwareOverview: (tenantId: string) =>
api
.get<FirmwareOverview>(`/api/tenants/${tenantId}/firmware/overview`)
.then((r) => r.data),
getFirmwareVersions: (params?: { architecture?: string; channel?: string }) =>
api
.get<FirmwareVersion[]>('/api/firmware/versions', { params })
.then((r) => r.data),
// -- Upgrades --
startUpgrade: (tenantId: string, data: UpgradeRequestData) =>
api
.post<{ status: string; job_id: string }>(
`/api/tenants/${tenantId}/firmware/upgrade`,
data,
)
.then((r) => r.data),
startMassUpgrade: (tenantId: string, data: MassUpgradeRequestData) =>
api
.post<{ status: string; rollout_group_id: string; jobs: Array<{ job_id: string; device_id: string }> }>(
`/api/tenants/${tenantId}/firmware/mass-upgrade`,
data,
)
.then((r) => r.data),
getUpgradeJobs: (
tenantId: string,
params?: { status?: string; device_id?: string; rollout_group_id?: string; page?: number },
) =>
api
.get<UpgradeJobsResponse>(`/api/tenants/${tenantId}/firmware/upgrades`, { params })
.then((r) => r.data),
getUpgradeJob: (tenantId: string, jobId: string) =>
api
.get<FirmwareUpgradeJob>(`/api/tenants/${tenantId}/firmware/upgrades/${jobId}`)
.then((r) => r.data),
getRolloutStatus: (tenantId: string, rolloutGroupId: string) =>
api
.get<RolloutStatus>(
`/api/tenants/${tenantId}/firmware/rollouts/${rolloutGroupId}`,
)
.then((r) => r.data),
cancelUpgrade: (tenantId: string, jobId: string) =>
api
.post<{ status: string }>(`/api/tenants/${tenantId}/firmware/upgrades/${jobId}/cancel`)
.then((r) => r.data),
retryUpgrade: (tenantId: string, jobId: string) =>
api
.post<{ status: string }>(`/api/tenants/${tenantId}/firmware/upgrades/${jobId}/retry`)
.then((r) => r.data),
resumeRollout: (tenantId: string, groupId: string) =>
api
.post<{ status: string }>(
`/api/tenants/${tenantId}/firmware/rollouts/${groupId}/resume`,
)
.then((r) => r.data),
abortRollout: (tenantId: string, groupId: string) =>
api
.post<{ status: string; aborted_count: number }>(
`/api/tenants/${tenantId}/firmware/rollouts/${groupId}/abort`,
)
.then((r) => r.data),
// -- Preferred channel --
setDevicePreferredChannel: (tenantId: string, deviceId: string, channel: string) =>
api
.patch<{ status: string }>(`/api/tenants/${tenantId}/devices/${deviceId}/preferred-channel`, {
preferred_channel: channel,
})
.then((r) => r.data),
setGroupPreferredChannel: (tenantId: string, groupId: string, channel: string) =>
api
.patch<{ status: string }>(
`/api/tenants/${tenantId}/device-groups/${groupId}/preferred-channel`,
{ preferred_channel: channel },
)
.then((r) => r.data),
}

View File

@@ -0,0 +1,199 @@
/**
* Network Intelligence API client -- types and functions for topology,
* VPN tunnels, device logs, client tracking, and interface utilization.
*/
import { api } from './api'
import { configEditorApi } from './configEditorApi'
// ---------------------------------------------------------------------------
// Topology Types
// ---------------------------------------------------------------------------
export interface TopologyNode {
id: string
hostname: string
ip: string
status: string
model: string | null
uptime: string | null
}
export interface TopologyEdge {
source: string
target: string
label: string
}
export interface TopologyResponse {
nodes: TopologyNode[]
edges: TopologyEdge[]
}
// ---------------------------------------------------------------------------
// VPN Types
// ---------------------------------------------------------------------------
export interface VpnTunnel {
type: 'wireguard' | 'ipsec' | 'l2tp'
remote_endpoint: string
status: string
uptime: string | null
rx_bytes: string | null
tx_bytes: string | null
local_address: string | null
// WireGuard-specific
public_key?: string
last_handshake?: string
// IPsec-specific
state?: string
}
export interface VpnResponse {
tunnels: VpnTunnel[]
device_id: string
}
// ---------------------------------------------------------------------------
// Log Types
// ---------------------------------------------------------------------------
export interface LogEntry {
time: string
topics: string
message: string
}
export interface LogsResponse {
logs: LogEntry[]
device_id: string
count: number
}
// ---------------------------------------------------------------------------
// Client Device Types
// ---------------------------------------------------------------------------
export interface ClientDevice {
mac: string
ip: string
interface: string
hostname: string | null
status: 'reachable' | 'stale'
signal_strength: string | null
tx_rate: string | null
rx_rate: string | null
uptime: string | null
is_wireless: boolean
}
export interface ClientsResponse {
clients: ClientDevice[]
device_id: string
timestamp: string
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseWireGuardPeers(entries: Record<string, string>[]): VpnTunnel[] {
return entries.map((entry) => ({
type: 'wireguard' as const,
remote_endpoint: entry['current-endpoint-address'] || entry['endpoint-address'] || 'unknown',
status: entry['current-endpoint-address'] ? 'connected' : 'waiting',
uptime: null,
rx_bytes: entry.rx || null,
tx_bytes: entry.tx || null,
local_address: entry['allowed-address'] || null,
public_key: entry['public-key'] || undefined,
last_handshake: entry['last-handshake'] || undefined,
}))
}
function parseIpsecPeers(entries: Record<string, string>[]): VpnTunnel[] {
return entries.map((entry) => ({
type: 'ipsec' as const,
remote_endpoint: entry['remote-address'] || 'unknown',
status: entry.state || 'established',
uptime: entry.uptime || null,
rx_bytes: entry['rx-bytes'] || null,
tx_bytes: entry['tx-bytes'] || null,
local_address: entry['local-address'] || null,
state: entry.state || undefined,
}))
}
function parseL2tpServers(entries: Record<string, string>[]): VpnTunnel[] {
return entries
.filter((entry) => entry.running === 'true' || entry['client-address'])
.map((entry) => ({
type: 'l2tp' as const,
remote_endpoint: entry['client-address'] || entry.name || 'unknown',
status: entry.running === 'true' ? 'connected' : 'inactive',
uptime: entry.uptime || null,
rx_bytes: null,
tx_bytes: null,
local_address: entry['local-address'] || null,
}))
}
// ---------------------------------------------------------------------------
// API
// ---------------------------------------------------------------------------
export const networkApi = {
/**
* Fetch network topology (nodes + edges) for a tenant.
* Cached on backend with 5-minute Redis TTL.
*/
getTopology: (tenantId: string) =>
api.get<TopologyResponse>(`/api/tenants/${tenantId}/topology`).then((r) => r.data),
/**
* Fetch VPN tunnels by browsing WireGuard peers, IPsec active-peers,
* and L2TP server interfaces via the existing config editor browse API.
* Uses Promise.allSettled so missing VPN types return empty (not errors).
*/
getVpnTunnels: async (tenantId: string, deviceId: string): Promise<VpnResponse> => {
const [wgResult, ipsecResult, l2tpResult] = await Promise.allSettled([
configEditorApi.browse(tenantId, deviceId, '/interface/wireguard/peers'),
configEditorApi.browse(tenantId, deviceId, '/ip/ipsec/active-peers'),
configEditorApi.browse(tenantId, deviceId, '/interface/l2tp-server/server'),
])
const tunnels: VpnTunnel[] = []
if (wgResult.status === 'fulfilled' && wgResult.value.success) {
tunnels.push(...parseWireGuardPeers(wgResult.value.entries))
}
if (ipsecResult.status === 'fulfilled' && ipsecResult.value.success) {
tunnels.push(...parseIpsecPeers(ipsecResult.value.entries))
}
if (l2tpResult.status === 'fulfilled' && l2tpResult.value.success) {
tunnels.push(...parseL2tpServers(l2tpResult.value.entries))
}
return { tunnels, device_id: deviceId }
},
/**
* Fetch connected client devices (ARP + DHCP + wireless) from the backend.
*/
getClients: (tenantId: string, deviceId: string) =>
api
.get<ClientsResponse>(`/api/tenants/${tenantId}/devices/${deviceId}/clients`)
.then((r) => r.data),
/**
* Fetch device syslog entries from the backend logs endpoint.
*/
getDeviceLogs: (
tenantId: string,
deviceId: string,
params?: { limit?: number; topic?: string; search?: string },
) =>
api
.get<LogsResponse>(`/api/tenants/${tenantId}/devices/${deviceId}/logs`, { params })
.then((r) => r.data),
}

View File

@@ -0,0 +1,42 @@
import { api } from './api'
export interface SMTPSettings {
smtp_host: string
smtp_port: number
smtp_user: string
smtp_use_tls: boolean
smtp_from_address: string
smtp_provider: string
smtp_password_set: boolean
source: 'database' | 'environment'
}
export async function getSMTPSettings(): Promise<SMTPSettings> {
const res = await api.get('/api/settings/smtp')
return res.data
}
export async function updateSMTPSettings(data: {
smtp_host: string
smtp_port: number
smtp_user?: string
smtp_password?: string
smtp_use_tls: boolean
smtp_from_address: string
smtp_provider: string
}): Promise<void> {
await api.put('/api/settings/smtp', data)
}
export async function testSMTPSettings(data: {
to: string
smtp_host?: string
smtp_port?: number
smtp_user?: string
smtp_password?: string
smtp_use_tls?: boolean
smtp_from_address?: string
}): Promise<{ success: boolean; message: string }> {
const res = await api.post('/api/settings/smtp/test', data)
return res.data
}

View File

@@ -0,0 +1,29 @@
export interface ShortcutDef {
key: string // Display key(s): "?", "g d", "j", "Cmd+K"
description: string // What it does
category: 'global' | 'navigation' | 'device-list'
}
export const shortcuts: ShortcutDef[] = [
// Global
{ key: '?', description: 'Show keyboard shortcuts', category: 'global' },
{ key: 'Cmd+K', description: 'Open command palette', category: 'global' },
{ key: '[', description: 'Toggle sidebar', category: 'global' },
// Navigation (g prefix = "go to")
{ key: 'g d', description: 'Go to Dashboard', category: 'navigation' },
{ key: 'g a', description: 'Go to Alerts', category: 'navigation' },
{ key: 'g t', description: 'Go to Topology', category: 'navigation' },
{ key: 'g f', description: 'Go to Firmware', category: 'navigation' },
// Device list
{ key: 'j', description: 'Next device', category: 'device-list' },
{ key: 'k', description: 'Previous device', category: 'device-list' },
{ key: 'Enter', description: 'Open selected device', category: 'device-list' },
]
export const categoryLabels: Record<ShortcutDef['category'], string> = {
global: 'Global',
navigation: 'Navigation',
'device-list': 'Device List',
}

View File

@@ -0,0 +1,335 @@
/**
* Simple Configuration Interface - Declarative Category Schema
*
* Maps 7 simplified configuration categories to RouterOS paths,
* field definitions, and friendly labels. Consumed by all Simple
* mode category panels and the category sidebar.
*/
import type { LucideIcon } from 'lucide-react'
import {
Globe,
Network,
Wifi,
ArrowLeftRight,
Shield,
Server,
Settings,
} from 'lucide-react'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SimpleCategory {
id: string
label: string
icon: LucideIcon
description: string
sections: SimpleCategorySection[]
}
export interface SimpleCategorySection {
label: string
routerosPath: string
isSingleton: boolean
fields: SimpleFieldDef[]
}
export interface SimpleFieldDef {
key: string
label: string
type: 'text' | 'ip' | 'cidr' | 'number' | 'boolean' | 'select' | 'password'
help?: string
placeholder?: string
required?: boolean
options?: { value: string; label: string }[]
validation?: (value: string) => string | null
minVersion?: number
}
// ---------------------------------------------------------------------------
// Validators
// ---------------------------------------------------------------------------
const IPV4_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/
const IPV6_REGEX = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/
export function isValidIp(value: string): boolean {
if (!value) return false
if (IPV4_REGEX.test(value)) {
return value.split('.').every((octet) => {
const n = parseInt(octet, 10)
return n >= 0 && n <= 255
})
}
return IPV6_REGEX.test(value)
}
export function isValidCidr(value: string): boolean {
if (!value) return false
const parts = value.split('/')
if (parts.length !== 2) return false
const [ip, prefix] = parts
if (!isValidIp(ip)) return false
const prefixNum = parseInt(prefix, 10)
if (isNaN(prefixNum)) return false
// IPv4: 0-32, IPv6: 0-128
const maxPrefix = ip.includes(':') ? 128 : 32
return prefixNum >= 0 && prefixNum <= maxPrefix
}
export function isValidPort(value: string): boolean {
const n = parseInt(value, 10)
return !isNaN(n) && n >= 1 && n <= 65535
}
// ---------------------------------------------------------------------------
// Category Definitions
// ---------------------------------------------------------------------------
export const SIMPLE_CATEGORIES: SimpleCategory[] = [
{
id: 'internet',
label: 'Internet Setup',
icon: Globe,
description: 'Configure how this router connects to the internet',
sections: [
{
label: 'DHCP Client',
routerosPath: '/ip/dhcp-client',
isSingleton: false,
fields: [
{ key: 'interface', label: 'WAN Interface', type: 'select', required: true },
{ key: 'use-peer-dns', label: 'Use ISP DNS', type: 'boolean', help: 'Accept DNS servers from your ISP' },
{ key: 'use-peer-ntp', label: 'Use ISP NTP', type: 'boolean', help: 'Accept time servers from your ISP' },
{ key: 'add-default-route', label: 'Add Default Route', type: 'boolean', help: 'Automatically create a default route via this connection' },
],
},
{
label: 'PPPoE Client',
routerosPath: '/interface/pppoe-client',
isSingleton: false,
fields: [
{ key: 'interface', label: 'Interface', type: 'select', required: true },
{ key: 'user', label: 'PPPoE Username', type: 'text', required: true, placeholder: 'ISP username' },
{ key: 'password', label: 'PPPoE Password', type: 'password', required: true },
{ key: 'service-name', label: 'Service Name', type: 'text', placeholder: 'Optional' },
{ key: 'use-peer-dns', label: 'Use ISP DNS', type: 'boolean' },
],
},
{
label: 'Static IP',
routerosPath: '/ip/address',
isSingleton: false,
fields: [
{ key: 'address', label: 'IP Address / Mask', type: 'cidr', required: true, placeholder: '192.168.1.100/24' },
{ key: 'interface', label: 'WAN Interface', type: 'select', required: true },
],
},
],
},
{
id: 'lan',
label: 'LAN & DHCP',
icon: Network,
description: 'Local network addresses, DHCP server, and IP pools',
sections: [
{
label: 'LAN Address',
routerosPath: '/ip/address',
isSingleton: false,
fields: [
{ key: 'address', label: 'IP Address / Mask', type: 'cidr', required: true, placeholder: '192.168.88.1/24', help: 'The IP address of this router on the local network' },
{ key: 'interface', label: 'Interface', type: 'text' },
],
},
{
label: 'DHCP Server',
routerosPath: '/ip/dhcp-server',
isSingleton: false,
fields: [
{ key: 'disabled', label: 'Enabled', type: 'boolean', help: 'Enable or disable the DHCP server' },
{ key: 'address-pool', label: 'Address Pool', type: 'text' },
],
},
{
label: 'DHCP Network',
routerosPath: '/ip/dhcp-server/network',
isSingleton: false,
fields: [
{ key: 'gateway', label: 'Gateway', type: 'ip', placeholder: '192.168.88.1' },
{ key: 'dns-server', label: 'DNS Servers', type: 'text', placeholder: '192.168.88.1', help: 'DNS servers provided to DHCP clients' },
{ key: 'lease-time', label: 'Lease Time', type: 'text', placeholder: '10m', help: 'How long a DHCP lease is valid' },
],
},
{
label: 'Address Pool',
routerosPath: '/ip/pool',
isSingleton: false,
fields: [
{ key: 'ranges', label: 'IP Range', type: 'text', placeholder: '192.168.88.10-192.168.88.254', help: 'Range of IP addresses for DHCP clients' },
],
},
],
},
{
id: 'wifi',
label: 'WiFi',
icon: Wifi,
description: 'Wireless network names, passwords, and bands',
sections: [
{
label: 'Wireless Interface',
routerosPath: '/interface/wifi',
isSingleton: false,
fields: [
{ key: 'ssid', label: 'Network Name (SSID)', type: 'text', required: true, placeholder: 'MyNetwork' },
{ key: 'security.passphrase', label: 'Password', type: 'password', required: true, help: 'WPA2/WPA3 passphrase (min 8 characters)' },
{ key: 'configuration.band', label: 'Band', type: 'select', options: [
{ value: '2ghz-ax', label: '2.4 GHz' },
{ value: '5ghz-ax', label: '5 GHz' },
] },
{ key: 'disabled', label: 'Enabled', type: 'boolean' },
],
},
],
},
{
id: 'port-forwarding',
label: 'Port Forwarding',
icon: ArrowLeftRight,
description: 'Forward external ports to internal servers',
sections: [
{
label: 'NAT Rules',
routerosPath: '/ip/firewall/nat',
isSingleton: false,
fields: [
{ key: 'dst-port', label: 'External Port', type: 'number', required: true, placeholder: '80', validation: (v) => isValidPort(v) ? null : 'Port must be 1-65535' },
{ key: 'protocol', label: 'Protocol', type: 'select', required: true, options: [
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
{ value: '6', label: 'TCP + UDP' },
] },
{ key: 'to-addresses', label: 'Internal IP Address', type: 'ip', required: true, placeholder: '192.168.88.100' },
{ key: 'to-ports', label: 'Internal Port', type: 'number', required: true, placeholder: '80', help: 'Leave same as external port if unchanged' },
{ key: 'comment', label: 'Description', type: 'text', placeholder: 'e.g., Web Server' },
],
},
],
},
{
id: 'firewall',
label: 'Firewall',
icon: Shield,
description: 'Basic firewall rules and address lists',
sections: [
{
label: 'Filter Rules',
routerosPath: '/ip/firewall/filter',
isSingleton: false,
fields: [
{ key: 'chain', label: 'Chain', type: 'select', required: true, options: [
{ value: 'input', label: 'Input' },
{ value: 'forward', label: 'Forward' },
] },
{ key: 'action', label: 'Action', type: 'select', required: true, options: [
{ value: 'accept', label: 'Accept' },
{ value: 'drop', label: 'Drop' },
{ value: 'reject', label: 'Reject' },
] },
{ key: 'protocol', label: 'Protocol', type: 'select', options: [
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
{ value: 'icmp', label: 'ICMP' },
] },
{ key: 'dst-port', label: 'Destination Port', type: 'number', placeholder: '22' },
{ key: 'src-address', label: 'Source Address', type: 'text', placeholder: '192.168.88.0/24', help: 'Leave blank to match any source' },
{ key: 'comment', label: 'Comment', type: 'text', placeholder: 'e.g., Allow SSH from LAN' },
],
},
{
label: 'Address Lists',
routerosPath: '/ip/firewall/address-list',
isSingleton: false,
fields: [
{ key: 'list', label: 'List Name', type: 'text', required: true },
{ key: 'address', label: 'Address', type: 'text', required: true, placeholder: '192.168.88.0/24' },
{ key: 'comment', label: 'Comment', type: 'text' },
],
},
],
},
{
id: 'dns',
label: 'DNS',
icon: Server,
description: 'DNS servers and local name resolution',
sections: [
{
label: 'DNS Settings',
routerosPath: '/ip/dns',
isSingleton: true,
fields: [
{ key: 'servers', label: 'Upstream Servers', type: 'text', required: true, placeholder: '8.8.8.8,8.8.4.4', help: 'Comma-separated list of DNS server IPs used for name resolution' },
{ key: 'allow-remote-requests', label: 'Allow Remote Requests', type: 'boolean', help: 'Allow devices on your network to use this router as their DNS server' },
{ key: 'cache-size', label: 'Cache Size (KiB)', type: 'number', placeholder: '2048', help: 'DNS cache size in KiB' },
],
},
{
label: 'Static Entries',
routerosPath: '/ip/dns/static',
isSingleton: false,
fields: [
{ key: 'name', label: 'Name', type: 'text', required: true, placeholder: 'myserver.local' },
{ key: 'address', label: 'Address', type: 'ip', required: true, placeholder: '192.168.88.100' },
{ key: 'type', label: 'Type', type: 'select', options: [
{ value: 'A', label: 'A' },
{ value: 'AAAA', label: 'AAAA' },
{ value: 'CNAME', label: 'CNAME' },
] },
],
},
],
},
{
id: 'system',
label: 'System',
icon: Settings,
description: 'Device identity, time, passwords, and maintenance',
sections: [
{
label: 'Identity',
routerosPath: '/system/identity',
isSingleton: true,
fields: [
{ key: 'name', label: 'Hostname', type: 'text', required: true, placeholder: 'e.g., Office-Router-1', help: 'A friendly name for this router, visible in the fleet dashboard' },
],
},
{
label: 'Clock',
routerosPath: '/system/clock',
isSingleton: true,
fields: [
{ key: 'time-zone-name', label: 'Timezone', type: 'text', placeholder: 'America/New_York', help: 'IANA timezone identifier (e.g., America/New_York, Europe/London)' },
],
},
{
label: 'NTP Client',
routerosPath: '/system/ntp/client',
isSingleton: true,
fields: [
{ key: 'enabled', label: 'NTP Enabled', type: 'boolean' },
{ key: 'server-dns-names', label: 'NTP Servers', type: 'text', placeholder: 'pool.ntp.org', help: 'Comma-separated NTP server hostnames' },
],
},
{
label: 'System Resource',
routerosPath: '/system/resource',
isSingleton: true,
fields: [],
},
],
},
]

View File

@@ -0,0 +1,71 @@
export interface SMTPPreset {
id: string;
label: string;
host: string;
port: number;
useTls: boolean;
helpText: string;
}
export const SMTP_PRESETS: SMTPPreset[] = [
{
id: "gmail",
label: "Gmail",
host: "smtp.gmail.com",
port: 587,
useTls: false, // STARTTLS on 587
helpText:
"Use an App Password — enable 2FA in Google Account → Security → App Passwords",
},
{
id: "microsoft365",
label: "Microsoft 365",
host: "smtp.office365.com",
port: 587,
useTls: false,
helpText:
"Use an App Password — go to account.microsoft.com → Security → App Passwords",
},
{
id: "fastmail",
label: "Fastmail",
host: "smtp.fastmail.com",
port: 465,
useTls: true, // implicit TLS on 465
helpText:
"Use an App Password — go to Settings → Privacy & Security → App Passwords",
},
{
id: "sendgrid",
label: "SendGrid",
host: "smtp.sendgrid.net",
port: 587,
useTls: false,
helpText: 'Username is "apikey", password is your SendGrid API key',
},
{
id: "amazon_ses",
label: "Amazon SES",
host: "email-smtp.us-east-1.amazonaws.com",
port: 587,
useTls: false,
helpText:
"Use SMTP credentials from AWS SES console (not IAM access keys)",
},
{
id: "mailpit",
label: "Mailpit (Dev)",
host: "mailpit",
port: 1025,
useTls: false,
helpText: "Local dev testing — Mailpit UI at http://localhost:8026",
},
{
id: "custom",
label: "Custom SMTP",
host: "",
port: 587,
useTls: false,
helpText: "Enter your SMTP server details manually",
},
];

44
frontend/src/lib/store.ts Normal file
View File

@@ -0,0 +1,44 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { applyTheme } from './theme'
interface UIState {
selectedTenantId: string | null
sidebarCollapsed: boolean
mobileSidebarOpen: boolean
theme: 'dark' | 'light'
setSelectedTenantId: (id: string | null) => void
setSidebarCollapsed: (collapsed: boolean) => void
toggleSidebar: () => void
setMobileSidebarOpen: (open: boolean) => void
setTheme: (theme: 'dark' | 'light') => void
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
selectedTenantId: null,
sidebarCollapsed: false,
mobileSidebarOpen: false,
theme: 'dark',
setSelectedTenantId: (id) => set({ selectedTenantId: id }),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }),
setTheme: (theme) => {
applyTheme(theme)
set({ theme })
},
}),
{
name: 'mikrotik-ui-state',
partialize: (state) => ({
sidebarCollapsed: state.sidebarCollapsed,
theme: state.theme,
selectedTenantId: state.selectedTenantId,
}),
},
),
)

View File

@@ -0,0 +1,127 @@
/**
* Templates API client -- TypeScript functions for config template CRUD,
* preview, push orchestration, and push status polling.
*/
import { api } from './api'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VariableDef {
name: string
type: 'string' | 'ip' | 'integer' | 'boolean' | 'subnet'
default: string | null
description: string | null
}
export interface TemplateResponse {
id: string
name: string
description: string | null
content: string
variables: VariableDef[]
tags: string[]
created_at: string
updated_at: string
}
export interface TemplateSummary {
id: string
name: string
description: string | null
tags: string[]
variable_count: number
created_at: string
updated_at: string
}
export interface PushJob {
device_id: string
hostname: string
status: 'pending' | 'pushing' | 'committed' | 'reverted' | 'failed'
error_message: string | null
started_at: string | null
completed_at: string | null
}
export interface PushStatus {
rollout_id: string
jobs: PushJob[]
}
export interface TemplateCreateData {
name: string
description?: string | null
content: string
variables: VariableDef[]
tags: string[]
}
export interface PushStartResult {
rollout_id: string
jobs: Array<{ job_id: string; device_id: string; device_hostname: string }>
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
export const templatesApi = {
list: (tenantId: string, tag?: string) =>
api
.get<TemplateSummary[]>(`/api/tenants/${tenantId}/templates`, {
params: tag ? { tag } : undefined,
})
.then((r) => r.data),
get: (tenantId: string, templateId: string) =>
api
.get<TemplateResponse>(`/api/tenants/${tenantId}/templates/${templateId}`)
.then((r) => r.data),
create: (tenantId: string, data: TemplateCreateData) =>
api
.post<TemplateResponse>(`/api/tenants/${tenantId}/templates`, data)
.then((r) => r.data),
update: (tenantId: string, templateId: string, data: TemplateCreateData) =>
api
.put<TemplateResponse>(`/api/tenants/${tenantId}/templates/${templateId}`, data)
.then((r) => r.data),
delete: (tenantId: string, templateId: string) =>
api.delete(`/api/tenants/${tenantId}/templates/${templateId}`).then((r) => r.data),
preview: (
tenantId: string,
templateId: string,
deviceId: string,
variables: Record<string, string>,
) =>
api
.post<{ rendered: string; device_hostname: string }>(
`/api/tenants/${tenantId}/templates/${templateId}/preview`,
{ device_id: deviceId, variables },
)
.then((r) => r.data),
push: (
tenantId: string,
templateId: string,
deviceIds: string[],
variables: Record<string, string>,
) =>
api
.post<PushStartResult>(
`/api/tenants/${tenantId}/templates/${templateId}/push`,
{ device_ids: deviceIds, variables },
)
.then((r) => r.data),
pushStatus: (tenantId: string, rolloutId: string) =>
api
.get<PushStatus>(`/api/tenants/${tenantId}/templates/push-status/${rolloutId}`)
.then((r) => r.data),
}

47
frontend/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,47 @@
const THEME_STORAGE_KEY = 'mikrotik-ui-state'
export type Theme = 'dark' | 'light'
/**
* Apply theme class to <html> element.
* Called both during initialization and on toggle.
*/
export function applyTheme(theme: Theme): void {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
/**
* Determine initial theme:
* 1. Check localStorage for saved preference
* 2. Fall back to prefers-color-scheme
* 3. Default to dark (network operators prefer it)
*
* Called BEFORE React renders to prevent flash of wrong theme.
*/
export function initializeTheme(): void {
let theme: Theme = 'dark' // default
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
if (parsed?.state?.theme === 'light' || parsed?.state?.theme === 'dark') {
theme = parsed.state.theme
}
} else {
// No saved preference -- respect OS preference
if (window.matchMedia('(prefers-color-scheme: light)').matches) {
theme = 'light'
}
}
} catch {
// localStorage unavailable or corrupt -- use default
}
applyTheme(theme)
}

View File

@@ -0,0 +1,83 @@
/**
* Transparency Log API client.
*
* Typed functions for the Data Access Transparency dashboard.
* Follows the same pattern as auditLogsApi in api.ts.
*/
import { api } from './api'
export interface TransparencyLogEntry {
id: string
action: string
device_name: string | null
device_id: string | null
justification: string | null
operator_email: string | null
correlation_id: string | null
resource_type: string | null
resource_id: string | null
ip_address: string | null
created_at: string
}
export interface TransparencyLogParams {
page?: number
per_page?: number
device_id?: string
justification?: string
action?: string
date_from?: string
date_to?: string
}
export interface TransparencyLogResponse {
items: TransparencyLogEntry[]
total: number
page: number
per_page: number
}
export interface TransparencyStats {
total_events: number
events_last_24h: number
unique_devices: number
justification_breakdown: Record<string, number>
}
export const transparencyApi = {
list: async (
tenantId: string,
params: TransparencyLogParams = {},
): Promise<TransparencyLogResponse> => {
const { data } = await api.get<TransparencyLogResponse>(
`/api/tenants/${tenantId}/transparency-logs`,
{ params },
)
return data
},
stats: async (tenantId: string): Promise<TransparencyStats> => {
const { data } = await api.get<TransparencyStats>(
`/api/tenants/${tenantId}/transparency-logs/stats`,
)
return data
},
exportCsv: async (
tenantId: string,
params: TransparencyLogParams = {},
): Promise<void> => {
const response = await api.get(
`/api/tenants/${tenantId}/transparency-logs/export`,
{ params, responseType: 'blob' },
)
const blob = new Blob([response.data as BlobPart], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transparency-logs.csv'
a.click()
window.URL.revokeObjectURL(url)
},
}

36
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,36 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatUptime(seconds: number | null | undefined): string {
if (!seconds) return '—'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}