feat(cloud-accounts): admin page with add/test/sync/import/delete

This commit is contained in:
Mitchell R 2026-05-23 02:34:03 +02:00
parent 7206847c97
commit af639b4d46
No known key found for this signature in database
6 changed files with 306 additions and 0 deletions

View file

@ -33,6 +33,7 @@ import { registerAccountRoutes } from "./routes-account.js";
import { registerFirmwareRoutes } from "./routes-firmware.js";
import { registerOsUpdateRoutes } from "./routes-os-updates.js";
import { registerStaticRoutes } from "./routes-static.js";
import { registerCloudRoutes } from "./routes-cloud.js";
// ---- Config -----------------------------------------------------------------
@ -167,6 +168,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
registerAccountRoutes(app, deps);
registerFirmwareRoutes(app, deps);
registerOsUpdateRoutes(app, deps);
registerCloudRoutes(app, deps);
// Auth-check endpoint for Angie auth_request subrequest.
// Returns 200 if session cookie is valid + admin role, 401 otherwise.

View file

@ -0,0 +1,225 @@
/**
* Admin cloud camera account routes.
*
* /admin/cloud-accounts list + add form
* /admin/cloud-accounts/:id/sync trigger camera sync
* /admin/cloud-accounts/:id/delete remove account
* /admin/cloud-accounts/:id/import import discovered cameras as BF cameras
*/
import { type H3, getRouterParam, readBody, createError } from "h3";
import { randomUUID } from "node:crypto";
import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js";
import { CLOUD_VENDORS, VENDOR_LABELS, getProvider, listProviders, type CloudVendor } from "../../shared/cloud-cameras/index.js";
export function registerCloudRoutes(app: H3, deps: AdminDeps): void {
app.get("/admin/cloud-accounts", async (event) => {
const user = event.context.user!;
const accounts = await deps.repo.listCloudAccounts();
const providers = listProviders();
return htmlPage(`<html><head><title>Cloud Accounts</title>
<link rel="stylesheet" href="/static/app.css">
</head><body style="font-family:system-ui;max-width:900px;margin:2rem auto;padding:0 1rem">
<h1>Cloud Camera Accounts</h1>
<p style="color:#666">Link your camera vendor cloud accounts. Server syncs cameras + delivers streaming URLs to kiosks. Credentials stored encrypted never leave the server.</p>
<div style="background:#f9fafb;border:1px solid #ddd;border-radius:6px;padding:1rem;margin-bottom:2rem">
<h2 style="margin:0 0 1rem;font-size:1.1rem">Add Account</h2>
<form method="post" action="/admin/cloud-accounts/add" style="display:grid;gap:0.75rem">
<div>
<label style="font-size:0.85rem;font-weight:600">Vendor</label>
<select name="vendor" class="form-input" required>
${CLOUD_VENDORS.map((v) => `<option value="${v}">${VENDOR_LABELS[v]}</option>`).join("")}
</select>
</div>
<div>
<label style="font-size:0.85rem;font-weight:600">Account Name</label>
<input name="name" type="text" class="form-input" required placeholder="e.g. Main Office Hik-Connect" />
</div>
<div id="cred-fields">
${providers.map((p) => p.credentialFields().map((f) =>
`<div data-vendor="${p.vendor}" style="display:none;margin-bottom:0.5rem">
<label style="font-size:0.85rem">${f.label}${f.required ? ' *' : ''}</label>
<input name="cred_${f.name}" type="${f.type}" class="form-input" ${f.required ? 'required' : ''} />
</div>`
).join("")).join("")}
</div>
<button type="submit" class="btn btn-primary">Add + Test</button>
</form>
<script>
document.querySelector('[name="vendor"]').addEventListener('change', function() {
var v = this.value;
document.querySelectorAll('#cred-fields [data-vendor]').forEach(function(el) {
el.style.display = el.getAttribute('data-vendor') === v ? '' : 'none';
el.querySelectorAll('input').forEach(function(inp) {
inp.required = el.getAttribute('data-vendor') === v && inp.closest('[data-vendor]').querySelector('label').textContent.includes('*');
});
});
});
document.querySelector('[name="vendor"]').dispatchEvent(new Event('change'));
</script>
</div>
<h2 style="font-size:1.1rem">Linked Accounts</h2>
${accounts.length === 0
? '<p style="color:#999">No cloud accounts linked yet.</p>'
: `<table style="width:100%;border-collapse:collapse">
<thead><tr>
<th style="text-align:left;padding:0.5rem;border-bottom:2px solid #ddd">Name</th>
<th style="text-align:left;padding:0.5rem;border-bottom:2px solid #ddd">Vendor</th>
<th style="text-align:left;padding:0.5rem;border-bottom:2px solid #ddd">Cameras</th>
<th style="text-align:left;padding:0.5rem;border-bottom:2px solid #ddd">Last Sync</th>
<th style="padding:0.5rem;border-bottom:2px solid #ddd"></th>
</tr></thead>
<tbody>
${accounts.map((a) => `<tr>
<td style="padding:0.5rem;border-bottom:1px solid #eee"><strong>${a.name}</strong></td>
<td style="padding:0.5rem;border-bottom:1px solid #eee">${VENDOR_LABELS[a.vendor as CloudVendor] ?? a.vendor}</td>
<td style="padding:0.5rem;border-bottom:1px solid #eee">${a.camera_count}</td>
<td style="padding:0.5rem;border-bottom:1px solid #eee;font-size:0.85rem">
${a.last_sync_at ?? '—'}
${a.last_sync_error ? `<br><span style="color:#c00;font-size:0.8rem">${a.last_sync_error}</span>` : ''}
</td>
<td style="padding:0.5rem;border-bottom:1px solid #eee">
<form method="post" action="/admin/cloud-accounts/${a.id}/sync" style="display:inline">
<button type="submit" class="btn btn-sm">Sync</button>
</form>
<form method="post" action="/admin/cloud-accounts/${a.id}/import" style="display:inline;margin-left:0.25rem">
<button type="submit" class="btn btn-sm btn-primary">Import</button>
</form>
<form method="post" action="/admin/cloud-accounts/${a.id}/delete" style="display:inline;margin-left:0.25rem"
onsubmit="return confirm('Delete this cloud account?')">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>`).join("")}
</tbody>
</table>`
}
<p style="margin-top:1rem"><a href="/admin/cameras"> Back to Cameras</a></p>
</body></html>`);
});
app.post("/admin/cloud-accounts/add", async (event) => {
const body = await readBody<Record<string, string>>(event);
const vendor = (body?.["vendor"] ?? "").trim() as CloudVendor;
const name = (body?.["name"] ?? "").trim();
if (!CLOUD_VENDORS.includes(vendor) || !name) {
throw createError({ statusCode: 400, statusMessage: "vendor + name required" });
}
const provider = getProvider(vendor);
if (!provider) throw createError({ statusCode: 400, statusMessage: `unknown vendor ${vendor}` });
// Extract credential fields.
const creds: Record<string, string> = {};
for (const f of provider.credentialFields()) {
const v = (body?.[`cred_${f.name}`] ?? "").trim();
if (f.required && !v) throw createError({ statusCode: 400, statusMessage: `${f.label} is required` });
if (v) creds[f.name] = v;
}
// Test credentials.
const test = await provider.testCredentials(creds);
if (!test.ok) {
throw createError({ statusCode: 400, statusMessage: `Credential test failed: ${test.error}` });
}
// Store encrypted.
const encrypted = deps.secrets.encryptString(JSON.stringify(creds), "cloud-creds");
await deps.repo.createCloudAccount({
id: randomUUID(),
vendor,
name,
credentials_encrypted: encrypted,
});
return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
});
app.post("/admin/cloud-accounts/:id/sync", async (event) => {
const id = String(getRouterParam(event, "id"));
const account = await deps.repo.getCloudAccount(id);
if (!account) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
const provider = getProvider(account.vendor as CloudVendor);
if (!provider) {
await deps.repo.updateCloudAccount(id, { last_sync_error: "unknown vendor" });
return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
}
let creds: Record<string, string>;
try {
creds = JSON.parse(deps.secrets.decryptString(account.credentials_encrypted, "cloud-creds"));
} catch {
await deps.repo.updateCloudAccount(id, { last_sync_error: "credential decrypt failed" });
return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
}
try {
const cameras = await provider.listCameras(creds);
await deps.repo.updateCloudAccount(id, {
camera_count: cameras.length,
last_sync_at: new Date().toISOString(),
last_sync_error: null,
} as any);
} catch (err) {
await deps.repo.updateCloudAccount(id, {
last_sync_error: (err as Error).message,
last_sync_at: new Date().toISOString(),
} as any);
}
return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
});
app.post("/admin/cloud-accounts/:id/import", async (event) => {
const id = String(getRouterParam(event, "id"));
const account = await deps.repo.getCloudAccount(id);
if (!account) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
const provider = getProvider(account.vendor as CloudVendor);
if (!provider) return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
let creds: Record<string, string>;
try {
creds = JSON.parse(deps.secrets.decryptString(account.credentials_encrypted, "cloud-creds"));
} catch {
return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
}
const cameras = await provider.listCameras(creds);
let imported = 0;
for (const cam of cameras) {
if (!cam.rtsp_url && !cam.relay_url) continue;
// Check if already imported (by vendor_id in camera name prefix).
const existingName = `${account.name}: ${cam.name}`;
const existing = await deps.repo.getCameraByName(existingName);
if (existing) continue;
await deps.repo.createCamera({
name: existingName,
type: "rtsp",
rtsp_url: cam.rtsp_url ?? cam.relay_url ?? null,
});
imported++;
}
await deps.repo.updateCloudAccount(id, {
camera_count: cameras.length,
last_sync_at: new Date().toISOString(),
last_sync_error: null,
} as any);
return new Response(null, { status: 302, headers: { location: `/admin/cloud-accounts` } });
});
app.post("/admin/cloud-accounts/:id/delete", async (event) => {
const id = String(getRouterParam(event, "id"));
await deps.repo.deleteCloudAccount(id);
return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });
});
}

View file

@ -12,6 +12,8 @@ import type {
AuditEntry,
AuditResult,
Camera,
CloudAccount,
CloudVendor,
CameraStream,
CameraType,
CellContentType,
@ -452,3 +454,17 @@ export function rowToKioskLog(r: Row): KioskLog {
received_at: s(r["received_at"]),
};
}
export function rowToCloudAccount(r: Row): CloudAccount {
return {
id: s(r["id"]),
vendor: s(r["vendor"]) as CloudVendor,
name: s(r["name"]),
credentials_encrypted: s(r["credentials_encrypted"]),
is_active: b(r["is_active"]),
last_sync_at: sn(r["last_sync_at"]),
last_sync_error: sn(r["last_sync_error"]),
camera_count: n(r["camera_count"]),
created_at: s(r["created_at"]),
};
}

View file

@ -20,6 +20,7 @@ import type {
Camera,
CameraStream,
CameraType,
CloudAccount,
Display,
Entity,
EntityType,
@ -59,6 +60,7 @@ import {
rowToApiKey,
rowToAuditEntry,
rowToCamera,
rowToCloudAccount,
rowToCameraStream,
rowToDisplay,
rowToEntity,
@ -2279,4 +2281,50 @@ export class Repository {
await this._run(`UPDATE labels SET ${sets.join(", ")} WHERE id = ?`, vals);
void this.notify("labels", "update", id);
}
// ===========================================================================
// cloud_accounts
// ===========================================================================
async listCloudAccounts(): Promise<CloudAccount[]> {
const rs = await this._all("SELECT * FROM cloud_accounts ORDER BY vendor, name");
return rs.map((r) => rowToCloudAccount(r as Record<string, unknown>));
}
async getCloudAccount(id: string): Promise<CloudAccount | null> {
const r = await this._get("SELECT * FROM cloud_accounts WHERE id = ?", [id]);
return r ? rowToCloudAccount(r as Record<string, unknown>) : null;
}
async createCloudAccount(input: {
id: string;
vendor: string;
name: string;
credentials_encrypted: string;
}): Promise<CloudAccount> {
await this._run(
`INSERT INTO cloud_accounts (id, vendor, name, credentials_encrypted) VALUES (?, ?, ?, ?)`,
[input.id, input.vendor, input.name, input.credentials_encrypted],
);
const a = await this.getCloudAccount(input.id);
if (!a) throw new Error("cloud account vanished after insert");
return a;
}
async updateCloudAccount(id: string, patch: Partial<CloudAccount>): Promise<void> {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
await this._run(`UPDATE cloud_accounts SET ${sets.join(", ")} WHERE id = ?`, vals);
}
async deleteCloudAccount(id: string): Promise<void> {
await this._run("DELETE FROM cloud_accounts WHERE id = ?", [id]);
}
}

View file

@ -333,6 +333,20 @@ export interface OsUpdateRollout {
created_by: number | null;
}
export type CloudVendor = "hikconnect" | "dahua" | "tuya" | "uniview" | "tplink";
export interface CloudAccount {
id: string;
vendor: CloudVendor;
name: string;
credentials_encrypted: string;
is_active: boolean;
last_sync_at: string | null;
last_sync_error: string | null;
camera_count: number;
created_at: string;
}
export interface Label {
id: number;
name: string;

View file

@ -52,6 +52,7 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />
<NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} />
<NavItem href="/admin/os-updates" label="OS Updates" icon="&#9679;" active={a === "os-updates"} />
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="&#9729;" active={a === "cloud"} />
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} />
<NavItem href="/admin/backup" label="Backup" icon="&#9788;" active={a === "backup"} />