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:
226
frontend/src/lib/alertsApi.ts
Normal file
226
frontend/src/lib/alertsApi.ts
Normal 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
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
258
frontend/src/lib/auth.ts
Normal 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)
|
||||
}
|
||||
176
frontend/src/lib/certificatesApi.ts
Normal file
176
frontend/src/lib/certificatesApi.ts
Normal 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),
|
||||
}
|
||||
80
frontend/src/lib/configEditorApi.ts
Normal file
80
frontend/src/lib/configEditorApi.ts
Normal 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),
|
||||
}
|
||||
166
frontend/src/lib/configPanelTypes.ts
Normal file
166
frontend/src/lib/configPanelTypes.ts
Normal 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}`)
|
||||
}
|
||||
137
frontend/src/lib/crypto/dataEncryption.ts
Normal file
137
frontend/src/lib/crypto/dataEncryption.ts
Normal 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);
|
||||
}
|
||||
123
frontend/src/lib/crypto/keyStore.ts
Normal file
123
frontend/src/lib/crypto/keyStore.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
236
frontend/src/lib/crypto/keys.ts
Normal file
236
frontend/src/lib/crypto/keys.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
170
frontend/src/lib/crypto/registration.ts
Normal file
170
frontend/src/lib/crypto/registration.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
83
frontend/src/lib/crypto/secretKey.ts
Normal file
83
frontend/src/lib/crypto/secretKey.ts
Normal 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;
|
||||
}
|
||||
331
frontend/src/lib/crypto/srp.ts
Normal file
331
frontend/src/lib/crypto/srp.ts
Normal 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;
|
||||
}
|
||||
70
frontend/src/lib/crypto/types.ts
Normal file
70
frontend/src/lib/crypto/types.ts
Normal 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;
|
||||
}
|
||||
52
frontend/src/lib/crypto/worker.ts
Normal file
52
frontend/src/lib/crypto/worker.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
117
frontend/src/lib/diffUtils.ts
Normal file
117
frontend/src/lib/diffUtils.ts
Normal 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;
|
||||
}
|
||||
58
frontend/src/lib/errors.ts
Normal file
58
frontend/src/lib/errors.ts
Normal 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.'
|
||||
}
|
||||
24
frontend/src/lib/eventsApi.ts
Normal file
24
frontend/src/lib/eventsApi.ts
Normal 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),
|
||||
}
|
||||
207
frontend/src/lib/firmwareApi.ts
Normal file
207
frontend/src/lib/firmwareApi.ts
Normal 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),
|
||||
}
|
||||
199
frontend/src/lib/networkApi.ts
Normal file
199
frontend/src/lib/networkApi.ts
Normal 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),
|
||||
}
|
||||
42
frontend/src/lib/settingsApi.ts
Normal file
42
frontend/src/lib/settingsApi.ts
Normal 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
|
||||
}
|
||||
29
frontend/src/lib/shortcuts.ts
Normal file
29
frontend/src/lib/shortcuts.ts
Normal 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',
|
||||
}
|
||||
335
frontend/src/lib/simpleConfigSchema.ts
Normal file
335
frontend/src/lib/simpleConfigSchema.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
71
frontend/src/lib/smtpPresets.ts
Normal file
71
frontend/src/lib/smtpPresets.ts
Normal 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
44
frontend/src/lib/store.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
127
frontend/src/lib/templatesApi.ts
Normal file
127
frontend/src/lib/templatesApi.ts
Normal 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
47
frontend/src/lib/theme.ts
Normal 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)
|
||||
}
|
||||
83
frontend/src/lib/transparencyApi.ts
Normal file
83
frontend/src/lib/transparencyApi.ts
Normal 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
36
frontend/src/lib/utils.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user