diff --git a/AGENTS.md b/AGENTS.md index 8312d55..5c70f07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,13 @@ It must support circuits, device rows, project devices, drag-and-drop restructur - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard repositories expose only active reads. Runtime writes belong in typed command repositories; direct integration fixtures belong under `tests/support`. +- Authentication: session-cookie login with two fixed roles (`admin`, `user`), + no self-signup. `src/server/auth/*` (password hashing, sessions, Express + middleware), `src/domain/services/user.service.ts`, + `src/db/repositories/user.repository.ts`. Every `/api/*` route requires a + signed-in user (`src/server/index.ts`); `/api/users/*` additionally + requires `admin`. Frontend gate: `AuthProvider` + `LoginGate` + (`src/app/layout.tsx`). See `docs/authentication.md` for the full picture. The supported runtime model is Circuit-First. The former Consumer UI, API, tables and upgrade tooling are removed; do not reintroduce them. @@ -704,6 +711,10 @@ for the early-phase power balance by DIN 276 cost group (see - logical feed link between distribution boards (`fedFrom` stays free text until the project owner decides whether it references a board, a feeding circuit or both) and the resulting hierarchical power summation +- configurable role/rights matrix instead of the fixed `admin`/`user` roles +- account lockout after failed login attempts +- email/one-time-link password provisioning, OIDC/Entra login +- linking user accounts to a personnel/employee master table Do not implement these while working on an unrelated phase. diff --git a/compose.dev.yaml b/compose.dev.yaml index b9c64b4..2c362bc 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -35,8 +35,10 @@ services: PORT: "3000" CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-debug}" - # Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf - # nicht aus dem LAN erreichbar sein. + AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" + AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" + AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" + # Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein. ports: - "127.0.0.1:3000:3000" # Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als diff --git a/compose.yaml b/compose.yaml index 3438f90..bab8c3d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -45,8 +45,15 @@ services: NODE_ENV: production PORT: "3000" LOG_LEVEL: "${LOG_LEVEL:-info}" - # Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung - # und wird ueber den Next.js-Rewrite im Web-Dienst erreicht. + # AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die + # users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte + # .env - siehe docs/AUTH.md. + AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" + AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" + AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" + COOKIE_SECURE: "${COOKIE_SECURE:-false}" + # Nicht auf dem Host veroeffentlicht: die API wird ueber den + # Next.js-Rewrite im Web-Dienst erreicht. expose: - "3000" # Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit diff --git a/docs/README.md b/docs/README.md index 01b5588..3569b42 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we bewusst noch nicht implementierte oder begrenzte Funktionen - [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul, Rechenweg, Datenbasis und Prüfnachweis der Tabellen +- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim + Anlegen eines Zugangs, Sessions, Bootstrap-Admin ## Entwicklung und Betrieb diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..3d197cf --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,61 @@ +# Anmeldung und Benutzerverwaltung + +Die Leistungsbilanz verlangt eine Anmeldung. Es gibt kein Self-Signup: jeder +Zugang wird von einem Administrator über **Config → Benutzer** angelegt. + +## Rollen + +Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`): + +- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich + (Benutzerverwaltung, eigenes Konto) +- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten, + Grobbilanz, …), kein Zugriff auf Config + +Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine +Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. + +## Ablauf beim Anlegen eines Zugangs + +1. Ein Administrator öffnet Config → Benutzer und legt einen neuen Zugang mit + Anmeldename, Name, Rolle und einem **Startpasswort** an, das er selbst + eintippt. +2. Der Zugang startet mit `mustChangePassword = true`. +3. Beim ersten Anmelden zeigt die Oberfläche statt der App ein Formular, das + ein eigenes Passwort verlangt, bevor es weitergeht. +4. "Passwort zurücksetzen" (ebenfalls in Config → Benutzer) setzt ein neues + Startpasswort und `mustChangePassword` wieder auf `true` – z. B. wenn + jemand sein Passwort vergessen hat. + +## Technischer Aufbau + +- **Datenmodell:** Tabelle `users` (`src/db/schema/users.ts`) – + `username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`, + `role`, `active`, `mustChangePassword`. +- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie + `lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h). Ein + Neustart der API beendet alle Sessions – für einen Single-Container-Dienst + ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle. +- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*` + (außer `/api/auth/login`, `/api/auth/status`, `/api/auth/logout`) und + erzwingt den Passwortwechsel, solange `mustChangePassword` gesetzt ist. + `/api/users/*` verlangt zusätzlich die Rolle `admin`. +- **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate` + (`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht + angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App. + +## Bootstrap-Admin + +`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe +`compose.yaml`) legen beim ersten Start einen Admin an – **nur falls die +`users`-Tabelle noch komplett leer ist**. Danach werden diese Variablen +ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte +gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht +in `compose.yaml` selbst. + +## Bewusst nicht umgesetzt (Stand jetzt) + +- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen +- Konten-Sperre nach Fehlversuchen +- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung +- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle diff --git a/src/app/config/benutzer/page.tsx b/src/app/config/benutzer/page.tsx new file mode 100644 index 0000000..134890d --- /dev/null +++ b/src/app/config/benutzer/page.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { userApi, type UserDto, type UserRole } from "../../../frontend/utils/auth-api"; +import { useAuth } from "../../../frontend/components/AuthProvider"; +import { USER_ROLES, USER_ROLE_LABEL } from "../../../shared/constants/user-roles"; + +const zeitpunkt = new Intl.DateTimeFormat("de-DE", { + dateStyle: "short", + timeStyle: "short", +}); + +export default function BenutzerPage() { + const { user: currentUser } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busyUserId, setBusyUserId] = useState(null); + const [resetPasswordFor, setResetPasswordFor] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const rows = await userApi.list(); + setUsers(rows); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Benutzer konnten nicht geladen werden."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function withBusy(userId: string, action: () => Promise) { + setBusyUserId(userId); + setError(null); + try { + await action(); + } catch (err) { + setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen."); + } finally { + setBusyUserId(null); + } + } + + async function changeRole(item: UserDto, role: UserRole) { + await withBusy(item.id, async () => { + const updated = await userApi.update(item.id, { role }); + setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); + }); + } + + async function toggleActive(item: UserDto) { + await withBusy(item.id, async () => { + const updated = await userApi.update(item.id, { active: !item.active }); + setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); + }); + } + + return ( +
+
+

+ Benutzer +

+
+

+ Es gibt kein Self-Signup: Jeder Zugang wird hier bewusst von einem + Administrator angelegt. Das Startpasswort wird direkt eingetragen; die + Person muss es beim ersten Anmelden durch ein eigenes ersetzen. +

+ + { + setUsers((list) => [...list, created]); + }} + /> + + {error &&

{error}

} + {loading &&

Lädt…

} + + {!loading && users.length > 0 && ( + + + + + + + + + + + + + + {users.map((item) => { + const isSelf = item.id === currentUser?.id; + const busy = busyUserId === item.id; + return ( + + + + + + + + + + ); + })} + +
NameAnmeldenameRolleAktivStatusAngelegtAktionen
{item.name}{item.username} + + + void toggleActive(item)} + /> + {item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}{zeitpunkt.format(new Date(item.createdAt))} + +
+ )} + + {!loading && users.length === 0 &&

Noch keine Benutzer angelegt.

} + + {resetPasswordFor && ( + setResetPasswordFor(null)} + onDone={(updated) => { + setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); + setResetPasswordFor(null); + }} + /> + )} +
+ ); +} + +function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) { + const [username, setUsername] = useState(""); + const [name, setName] = useState(""); + const [role, setRole] = useState("user"); + const [initialPassword, setInitialPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const created = await userApi.create({ username, name, role, initialPassword }); + onCreated(created); + setUsername(""); + setName(""); + setRole("user"); + setInitialPassword(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Benutzer konnte nicht angelegt werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ + setUsername(event.target.value)} + required + /> +
+
+ + setName(event.target.value)} + required + /> +
+
+ + +
+
+ + setInitialPassword(event.target.value)} + minLength={8} + required + /> +
+
+ +
+ {error && ( +
+

{error}

+
+ )} +
+ ); +} + +function PasswortZuruecksetzenDialog({ + item, + onClose, + onDone, +}: { + item: UserDto; + onClose: () => void; + onDone: (updated: UserDto) => void; +}) { + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const updated = await userApi.resetPassword(item.id, newPassword); + onDone(updated); + } catch (err) { + setError(err instanceof Error ? err.message : "Passwort konnte nicht zurückgesetzt werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

Passwort zurücksetzen für {item.name}

+
+ + setNewPassword(event.target.value)} + minLength={8} + autoFocus + required + /> +
+ {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/src/app/config/konto/page.tsx b/src/app/config/konto/page.tsx new file mode 100644 index 0000000..2b58ee7 --- /dev/null +++ b/src/app/config/konto/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { authApi } from "../../../frontend/utils/auth-api"; +import { useAuth } from "../../../frontend/components/AuthProvider"; +import { USER_ROLE_LABEL } from "../../../shared/constants/user-roles"; + +export default function KontoPage() { + const { user } = useAuth(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newPasswordRepeat, setNewPasswordRepeat] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (newPassword !== newPasswordRepeat) { + setError("Die beiden neuen Passwörter stimmen nicht überein."); + return; + } + setSubmitting(true); + setError(null); + setSuccess(false); + try { + await authApi.changePassword(currentPassword, newPassword); + setCurrentPassword(""); + setNewPassword(""); + setNewPasswordRepeat(""); + setSuccess(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Konto

+ {user && ( +

+ Angemeldet als {user.name} ({user.username}) ·{" "} + {USER_ROLE_LABEL[user.role]} +

+ )} + +

Passwort ändern

+
+
+ + setCurrentPassword(event.target.value)} + autoComplete="current-password" + required + /> +
+
+ + setNewPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+
+ + setNewPasswordRepeat(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+ {error &&

{error}

} + {success &&

Passwort wurde geändert.

} + +
+
+ ); +} diff --git a/src/app/config/layout.tsx b/src/app/config/layout.tsx new file mode 100644 index 0000000..1356d78 --- /dev/null +++ b/src/app/config/layout.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useAuth } from "../../frontend/components/AuthProvider"; + +const CONFIG_TABS = [ + { href: "/config/benutzer", label: "Benutzer" }, + { href: "/config/konto", label: "Konto" }, +] as const; + +export default function ConfigLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const { status, isAdmin } = useAuth(); + + useEffect(() => { + // LoginGate already guarantees a signed-in user with a set password by + // the time this layout renders; only the role still needs checking. + if (status === "signed-in" && !isAdmin) { + router.replace("/"); + } + }, [status, isAdmin, router]); + + if (status !== "signed-in" || !isAdmin) { + return null; + } + + return ( +
+
+

Config

+
+ + + + {children} +
+ ); +} diff --git a/src/app/config/page.tsx b/src/app/config/page.tsx new file mode 100644 index 0000000..7897dc7 --- /dev/null +++ b/src/app/config/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function ConfigPage() { + redirect("/config/benutzer"); +} diff --git a/src/app/globals.css b/src/app/globals.css index de46d37..f670173 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2716,3 +2716,116 @@ button.structure-entry-main:focus-visible { justify-content: center; } } + +/* ── Anmeldung (Sidebar-Config-Link, Identität, Login-Gate) ─────────── */ + +.sidebar-nav-bottom { + flex: 0 0 auto; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + +.sidebar-identity { + display: flex; + flex-direction: column; + gap: 1px; + padding: 8px 14px 10px; +} + +.sidebar-identity-label { + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(199, 214, 224, 0.7); +} + +.sidebar-identity-name { + font-size: 0.88rem; + font-weight: 600; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-logout { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; + padding: 8px 14px; + border-radius: 8px; + color: #c7d6e0; + font-size: 0.85rem; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.16); + background: transparent; + width: 100%; + cursor: pointer; +} + +.sidebar-logout:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.sidebar-collapsed .sidebar-identity { + display: none; +} + +.login-gate-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-md); + background: linear-gradient( + 160deg, + var(--color-primary-deep) 0%, + var(--color-primary-dark) 45%, + var(--color-primary) 100% + ); +} + +.login-gate-card { + width: 100%; + max-width: 360px; + padding: 28px 26px; +} + +.config-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.12)); + margin-bottom: var(--space-md); +} + +.config-tab { + padding: 8px 16px; + border-radius: 6px 6px 0 0; + color: inherit; + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + opacity: 0.7; +} + +.config-tab:hover { + opacity: 1; + text-decoration: none; +} + +.config-tab.active { + opacity: 1; + box-shadow: inset 0 -2px 0 var(--color-primary); +} + +.modal-backdrop-inline { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1050; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cd2407b..c830c99 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,8 @@ import Script from "next/script"; import "bootstrap/dist/css/bootstrap.min.css"; import "./globals.css"; import { AppShell } from "../frontend/components/AppShell"; +import { AuthProvider } from "../frontend/components/AuthProvider"; +import { LoginGate } from "../frontend/components/LoginGate"; export const metadata: Metadata = { title: "Leistungsbilanz", @@ -36,7 +38,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac - {children} + + + {children} + + ); diff --git a/src/db/migrations/0012_lively_turbo.sql b/src/db/migrations/0012_lively_turbo.sql new file mode 100644 index 0000000..6c07010 --- /dev/null +++ b/src/db/migrations/0012_lively_turbo.sql @@ -0,0 +1,13 @@ +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `name` text NOT NULL, + `role` text DEFAULT 'user' NOT NULL, + `active` integer DEFAULT true NOT NULL, + `must_change_password` integer DEFAULT true NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); \ No newline at end of file diff --git a/src/db/migrations/meta/0012_snapshot.json b/src/db/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..ed30f77 --- /dev/null +++ b/src/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,3017 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7c6307b6-2516-445c-a088-c8ad746b9fde", + "prevId": "63f27b3a-7378-462d-8119-b67d67903dbb", + "tables": { + "buildings": { + "name": "buildings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_project_id_projects_id_fk": { + "name": "buildings_project_id_projects_id_fk", + "tableFrom": "buildings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "board_number": { + "name": "board_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fed_from": { + "name": "fed_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_assets": { + "name": "document_branding_assets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_settings": { + "name": "document_branding_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_balances": { + "name": "estimate_balances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_iso": { + "name": "date_iso", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserve_av_percent": { + "name": "reserve_av_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserve_sv_percent": { + "name": "reserve_sv_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_balances_project_id_idx": { + "name": "estimate_balances_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_balances_project_id_projects_id_fk": { + "name": "estimate_balances_project_id_projects_id_fk", + "tableFrom": "estimate_balances", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_groups": { + "name": "estimate_groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "balance_id": { + "name": "balance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editor": { + "name": "editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_groups_balance_id_idx": { + "name": "estimate_groups_balance_id_idx", + "columns": [ + "balance_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_groups_balance_id_estimate_balances_id_fk": { + "name": "estimate_groups_balance_id_estimate_balances_id_fk", + "tableFrom": "estimate_groups", + "tableTo": "estimate_balances", + "columnsFrom": [ + "balance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_rows": { + "name": "estimate_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "supply": { + "name": "supply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'piece'" + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_per_unit_w": { + "name": "power_per_unit_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "safety_supply": { + "name": "safety_supply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "estimate_rows_group_id_idx": { + "name": "estimate_rows_group_id_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_rows_group_id_estimate_groups_id_fk": { + "name": "estimate_rows_group_id_estimate_groups_id_fk", + "tableFrom": "estimate_rows", + "tableTo": "estimate_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "site_address": { + "name": "site_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_lead": { + "name": "project_lead", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_editor": { + "name": "project_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "area_m2": { + "name": "area_m2", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 90c3bd0..ddde03a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1790531351492, "tag": "0011_glossy_norman_osborn", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1790617581434, + "tag": "0012_lively_turbo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/user.repository.ts b/src/db/repositories/user.repository.ts new file mode 100644 index 0000000..071fc33 --- /dev/null +++ b/src/db/repositories/user.repository.ts @@ -0,0 +1,87 @@ +import crypto from "node:crypto"; +import { asc, eq } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { users } from "../schema/users.js"; +import type { UserRole } from "../../shared/constants/user-roles.js"; + +export interface CreateUserRow { + username: string; + name: string; + role: UserRole; + passwordHash: string; +} + +export class UserRepository { + constructor(private readonly database: AppDatabase) {} + + async list() { + const db = this.database; + return db.select().from(users).orderBy(asc(users.name)); + } + + async count() { + const db = this.database; + const rows = await db.select({ id: users.id }).from(users).limit(1); + return rows.length; + } + + async findById(userId: string) { + const db = this.database; + const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + return row ?? null; + } + + async findByUsername(username: string) { + const db = this.database; + const [row] = await db + .select() + .from(users) + .where(eq(users.username, username.trim().toLowerCase())) + .limit(1); + return row ?? null; + } + + async create(input: CreateUserRow) { + const db = this.database; + const id = crypto.randomUUID(); + await db.insert(users).values({ + id, + username: input.username.trim().toLowerCase(), + name: input.name, + role: input.role, + passwordHash: input.passwordHash, + active: true, + mustChangePassword: true, + }); + return this.findById(id); + } + + async updateRoleAndActive( + userId: string, + patch: { role?: UserRole; active?: boolean } + ) { + const db = this.database; + await db + .update(users) + .set({ + ...(patch.role !== undefined ? { role: patch.role } : {}), + ...(patch.active !== undefined ? { active: patch.active } : {}), + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); + return this.findById(userId); + } + + async setPassword( + userId: string, + passwordHash: string, + mustChangePassword: boolean + ) { + const db = this.database; + await db + .update(users) + .set({ passwordHash, mustChangePassword, updatedAt: new Date() }) + .where(eq(users.id, userId)); + return this.findById(userId); + } +} diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts new file mode 100644 index 0000000..825b5e1 --- /dev/null +++ b/src/db/schema/users.ts @@ -0,0 +1,27 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable( + "users", + { + id: text("id").primaryKey(), + username: text("username").notNull(), + passwordHash: text("password_hash").notNull(), + name: text("name").notNull(), + role: text("role").notNull().default("user"), + active: integer("active", { mode: "boolean" }).notNull().default(true), + mustChangePassword: integer("must_change_password", { mode: "boolean" }) + .notNull() + .default(true), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => [uniqueIndex("users_username_unique").on(table.username)] +); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; diff --git a/src/domain/services/user.service.ts b/src/domain/services/user.service.ts new file mode 100644 index 0000000..55720c1 --- /dev/null +++ b/src/domain/services/user.service.ts @@ -0,0 +1,104 @@ +import type { UserRepository } from "../../db/repositories/user.repository.js"; +import { hashPassword, verifyPassword } from "../../server/auth/password.js"; +import type { UserRole } from "../../shared/constants/user-roles.js"; +import type { AuthConfig } from "../../server/auth/auth-config.js"; +import { createLogger } from "../../shared/logging/logger.js"; + +const logger = createLogger("user-service"); + +export class InvalidCredentialsError extends Error { + constructor() { + super("Anmeldung fehlgeschlagen."); + } +} + +export class UsernameTakenError extends Error { + constructor() { + super("Dieser Anmeldename ist bereits vergeben."); + } +} + +export class WrongPasswordError extends Error { + constructor() { + super("Das aktuelle Passwort ist falsch."); + } +} + +export class UserService { + constructor( + private readonly repository: UserRepository, + private readonly authConfig: AuthConfig + ) {} + + /** + * Creates the bootstrap admin from env vars, but only while the users + * table is still completely empty. After the first account exists, every + * further account is created through Config -> Benutzer, and the bootstrap + * env vars are ignored - that's the whole point of them being a one-time + * escape hatch instead of a standing backdoor. + */ + async ensureBootstrapAdmin(): Promise { + if (!this.authConfig.bootstrap) return; + const existingCount = await this.repository.count(); + if (existingCount > 0) return; + const passwordHash = await hashPassword(this.authConfig.bootstrap.password); + await this.repository.create({ + username: this.authConfig.bootstrap.username, + name: "Administrator", + role: "admin", + passwordHash, + }); + logger.info("bootstrap admin created", { + username: this.authConfig.bootstrap.username, + }); + } + + async authenticate(username: string, password: string) { + const user = await this.repository.findByUsername(username); + // Always hash something, even for an unknown user: otherwise the + // response time would leak which usernames exist. + const passwordHash = user?.passwordHash ?? (await hashPassword("placeholder")); + const passwordMatches = await verifyPassword(password, passwordHash); + if (!user || !user.active || !passwordMatches) { + throw new InvalidCredentialsError(); + } + return user; + } + + async createUser(input: { + username: string; + name: string; + role: UserRole; + initialPassword: string; + }) { + const existing = await this.repository.findByUsername(input.username); + if (existing) throw new UsernameTakenError(); + const passwordHash = await hashPassword(input.initialPassword); + return this.repository.create({ + username: input.username, + name: input.name, + role: input.role, + passwordHash, + }); + } + + async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) { + return this.repository.updateRoleAndActive(userId, patch); + } + + /** Admin-triggered reset with a new start password; forces a change on next login. */ + async resetPassword(userId: string, newPassword: string) { + const passwordHash = await hashPassword(newPassword); + return this.repository.setPassword(userId, passwordHash, true); + } + + /** Self-service change by the signed-in user; clears mustChangePassword. */ + async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) { + const user = await this.repository.findById(userId); + if (!user) throw new WrongPasswordError(); + const currentMatches = await verifyPassword(currentPassword, user.passwordHash); + if (!currentMatches) throw new WrongPasswordError(); + const passwordHash = await hashPassword(newPassword); + return this.repository.setPassword(userId, passwordHash, false); + } +} diff --git a/src/frontend/components/AuthProvider.tsx b/src/frontend/components/AuthProvider.tsx new file mode 100644 index 0000000..02ab833 --- /dev/null +++ b/src/frontend/components/AuthProvider.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { authApi, type AuthUserDto } from "../utils/auth-api"; + +type AuthStatus = "loading" | "signed-in" | "signed-out"; + +interface AuthContextValue { + status: AuthStatus; + user: AuthUserDto | undefined; + isAdmin: boolean; + reload: () => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [status, setStatus] = useState("loading"); + const [user, setUser] = useState(undefined); + + const reload = useCallback(async () => { + try { + const current = await authApi.me(); + setUser(current); + setStatus("signed-in"); + } catch { + setUser(undefined); + setStatus("signed-out"); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + async function logout() { + try { + await authApi.logout(); + } finally { + setUser(undefined); + setStatus("signed-out"); + } + } + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/src/frontend/components/LoginGate.tsx b/src/frontend/components/LoginGate.tsx new file mode 100644 index 0000000..23a2aeb --- /dev/null +++ b/src/frontend/components/LoginGate.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import { authApi } from "../utils/auth-api"; +import { useAuth } from "./AuthProvider"; + +export function LoginGate({ children }: { children: React.ReactNode }) { + const { status, user, reload } = useAuth(); + + if (status === "loading") { + return ( +
+

Lädt…

+
+ ); + } + + if (status === "signed-out") { + return ( +
+ +
+ ); + } + + if (user?.mustChangePassword) { + return ( +
+ +
+ ); + } + + return <>{children}; +} + +function LoginForm({ onSignedIn }: { onSignedIn: () => Promise }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + await authApi.login(username, password); + await onSignedIn(); + } catch (err) { + setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Leistungsbilanz

+
+ + setUsername(event.target.value)} + autoFocus + autoComplete="username" + required + /> +
+
+ + setPassword(event.target.value)} + autoComplete="current-password" + required + /> +
+ {error &&

{error}

} + +
+ ); +} + +function ForcedPasswordChangeForm({ onChanged }: { onChanged: () => Promise }) { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newPasswordRepeat, setNewPasswordRepeat] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (newPassword !== newPasswordRepeat) { + setError("Die beiden neuen Passwörter stimmen nicht überein."); + return; + } + setSubmitting(true); + setError(null); + try { + await authApi.changePassword(currentPassword, newPassword); + await onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Neues Passwort setzen

+

+ Dieser Zugang wurde mit einem Startpasswort angelegt. Bitte vor der + weiteren Nutzung ein eigenes Passwort vergeben. +

+
+ + setCurrentPassword(event.target.value)} + autoComplete="current-password" + autoFocus + required + /> +
+
+ + setNewPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+
+ + setNewPasswordRepeat(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+ {error &&

{error}

} + +
+ ); +} diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index 395cd7c..bb8aecd 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { listProjects } from "../utils/api"; import type { ProjectDto } from "../types"; import { ThemeToggle } from "./ThemeToggle"; +import { useAuth } from "./AuthProvider"; // Anchors on the project overview page (/projects/[id]). const PROJECT_SECTIONS = [ @@ -30,6 +31,7 @@ interface SidebarProps { export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { const pathname = usePathname(); + const { user, isAdmin, logout } = useAuth(); const projectMatch = pathname?.match(PROJECT_PATH_PATTERN); const activeProjectId = projectMatch ? projectMatch[1] : null; const projectSubPath = projectMatch?.[2] ?? ""; @@ -179,8 +181,31 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { )} + {isAdmin && ( + + )}
+ {user && !isCollapsed && ( +
+ Angemeldet als + {user.name} +
+ )} + {user && ( + + )}
); diff --git a/src/frontend/utils/auth-api.ts b/src/frontend/utils/auth-api.ts new file mode 100644 index 0000000..f7057f9 --- /dev/null +++ b/src/frontend/utils/auth-api.ts @@ -0,0 +1,86 @@ +// Small, self-contained fetch helper for the auth/user endpoints - kept +// separate from the large domain api.ts so this feature doesn't have to +// touch that file's shared `request()` internals. + +export type UserRole = "admin" | "user"; + +export interface AuthStatusDto { + signedIn: boolean; + mustChangePassword: boolean; +} + +export interface AuthUserDto { + id: string; + username: string; + name: string; + role: UserRole; + mustChangePassword: boolean; +} + +export interface UserDto { + id: string; + username: string; + name: string; + role: UserRole; + active: boolean; + mustChangePassword: boolean; + createdAt: string; +} + +export class AuthApiError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + } +} + +async function request(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + cache: "no-store", + }); + if (!response.ok) { + let message = `Anfrage fehlgeschlagen (Status ${response.status})`; + try { + const body = (await response.json()) as { error?: unknown }; + if (typeof body.error === "string") message = body.error; + } catch { + // Keep the generic message for non-JSON error responses. + } + throw new AuthApiError(message, response.status); + } + if (response.status === 204) return undefined as T; + return response.json() as Promise; +} + +export const authApi = { + status: () => request("/api/auth/status"), + login: (username: string, password: string) => + request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }), + me: () => request("/api/auth/me"), + changePassword: (currentPassword: string, newPassword: string) => + request<{ changed: true }>("/api/auth/change-password", { + method: "POST", + body: JSON.stringify({ currentPassword, newPassword }), + }), + logout: () => request<{ signedOut: true }>("/api/auth/logout", { method: "POST" }), +}; + +export const userApi = { + list: () => request("/api/users"), + create: (input: { username: string; name: string; role: UserRole; initialPassword: string }) => + request("/api/users", { method: "POST", body: JSON.stringify(input) }), + update: (userId: string, patch: { role?: UserRole; active?: boolean }) => + request(`/api/users/${userId}`, { method: "PATCH", body: JSON.stringify(patch) }), + resetPassword: (userId: string, newPassword: string) => + request(`/api/users/${userId}/reset-password`, { + method: "POST", + body: JSON.stringify({ newPassword }), + }), +}; diff --git a/src/server/auth/auth-config.ts b/src/server/auth/auth-config.ts new file mode 100644 index 0000000..d2157f4 --- /dev/null +++ b/src/server/auth/auth-config.ts @@ -0,0 +1,44 @@ +export const SESSION_COOKIE = "lb_session"; + +const DEFAULT_SESSION_HOURS = 12; + +export interface AuthConfig { + sessionTtlMs: number; + cookieSecure: boolean; + /** + * Only used to create the very first admin when the users table is still + * empty - after that, every account is created through Config -> Benutzer. + * Unset means: no bootstrap account, the table must already have an admin. + */ + bootstrap: { username: string; password: string } | null; +} + +export class AuthConfigError extends Error {} + +export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig { + const hours = Number(env.AUTH_SESSION_HOURS ?? DEFAULT_SESSION_HOURS); + if (!Number.isFinite(hours) || hours <= 0) { + throw new AuthConfigError("AUTH_SESSION_HOURS must be a positive number."); + } + + const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase(); + const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? ""; + if (Boolean(bootstrapUsername) !== Boolean(bootstrapPassword)) { + throw new AuthConfigError( + "AUTH_BOOTSTRAP_USERNAME and AUTH_BOOTSTRAP_PASSWORT belong together - set both or neither." + ); + } + if (bootstrapPassword && bootstrapPassword.length < 12) { + throw new AuthConfigError( + "AUTH_BOOTSTRAP_PASSWORT must be at least 12 characters long." + ); + } + + return { + sessionTtlMs: hours * 60 * 60 * 1000, + cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true", + bootstrap: bootstrapUsername + ? { username: bootstrapUsername, password: bootstrapPassword } + : null, + }; +} diff --git a/src/server/auth/auth.middleware.ts b/src/server/auth/auth.middleware.ts new file mode 100644 index 0000000..53a1767 --- /dev/null +++ b/src/server/auth/auth.middleware.ts @@ -0,0 +1,111 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; +import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js"; +import { readCookie } from "./cookie.js"; +import type { SessionStore } from "./sessions.js"; +import type { UserRepository } from "../../db/repositories/user.repository.js"; +import type { UserRole } from "../../shared/constants/user-roles.js"; + +export interface AuthUser { + id: string; + username: string; + name: string; + role: UserRole; + mustChangePassword: boolean; +} + +declare global { + namespace Express { + interface Request { + authUser?: AuthUser; + } + } +} + +export function sessionIdFromRequest(req: { + headers: { cookie?: string | undefined }; +}): string | undefined { + return readCookie(req.headers.cookie, SESSION_COOKIE); +} + +export async function identityFromRequest( + req: { headers: { cookie?: string | undefined } }, + sessions: SessionStore, + users: UserRepository, + slideExpiry = true +): Promise { + const session = sessions.get(sessionIdFromRequest(req), slideExpiry); + if (!session) return undefined; + const user = await users.findById(session.userId); + if (!user || !user.active) return undefined; + return { + id: user.id, + username: user.username, + name: user.name, + role: user.role as UserRole, + mustChangePassword: user.mustChangePassword, + }; +} + +export function cookieOptions(authConfig: AuthConfig) { + return { + httpOnly: true, + sameSite: "lax" as const, + secure: authConfig.cookieSecure, + path: "/", + maxAge: authConfig.sessionTtlMs, + }; +} + +/** Protects /api. Only the login route and the status check stay reachable without a session. */ +export function createAuthGuard( + authConfig: AuthConfig, + sessions: SessionStore, + users: UserRepository, + openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] +): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (openPaths.includes(req.path)) { + next(); + return; + } + identityFromRequest(req, sessions, users) + .then((identity) => { + if (!identity) { + res.status(401).json({ error: "Nicht angemeldet." }); + return; + } + req.authUser = identity; + const sessionId = sessionIdFromRequest(req); + if (sessionId) { + res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig)); + } + next(); + }) + .catch(next); + }; +} + +/** Blocks everything except logout and the password change while a start password is still in use. */ +export function requirePasswordChanged(): RequestHandler { + const allowed = new Set(["/auth/me", "/auth/change-password", "/auth/logout"]); + return (req: Request, res: Response, next: NextFunction) => { + if (!req.authUser?.mustChangePassword || allowed.has(req.path)) { + next(); + return; + } + res.status(403).json({ + error: "Bitte zuerst ein eigenes Passwort setzen.", + mustChangePassword: true, + }); + }; +} + +export function requireRole(role: UserRole): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (req.authUser?.role !== role) { + res.status(403).json({ error: "Keine Berechtigung für diese Aktion." }); + return; + } + next(); + }; +} diff --git a/src/server/auth/cookie.ts b/src/server/auth/cookie.ts new file mode 100644 index 0000000..0204ca3 --- /dev/null +++ b/src/server/auth/cookie.ts @@ -0,0 +1,15 @@ +/** Reads one cookie value from a raw "Cookie" request header. No cookie-parser dependency needed for a single cookie. */ +export function readCookie( + header: string | undefined, + name: string +): string | undefined { + if (!header) return undefined; + for (const part of header.split(";")) { + const separatorIndex = part.indexOf("="); + if (separatorIndex === -1) continue; + const key = part.slice(0, separatorIndex).trim(); + if (key !== name) continue; + return decodeURIComponent(part.slice(separatorIndex + 1).trim()); + } + return undefined; +} diff --git a/src/server/auth/password.ts b/src/server/auth/password.ts new file mode 100644 index 0000000..96814e1 --- /dev/null +++ b/src/server/auth/password.ts @@ -0,0 +1,63 @@ +import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from "node:crypto"; + +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const KEY_LENGTH = 64; + +// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only +// resolves to scrypt's 3-argument overload, so the 4-argument form used for +// verification (with explicit N/r/p) doesn't type-check through it. +function scryptAsync( + password: string, + salt: Buffer, + keyLength: number, + options?: ScryptOptions +): Promise { + return new Promise((resolve, reject) => { + const callback = (error: Error | null, derivedKey: Buffer) => { + if (error) reject(error); + else resolve(derivedKey); + }; + if (options) { + scrypt(password, salt, keyLength, options, callback); + } else { + scrypt(password, salt, keyLength, callback); + } + }); +} + +// Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the +// hash so they can be tuned later without invalidating existing rows. +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const derivedKey = await scryptAsync(password, salt, KEY_LENGTH); + return [ + "scrypt", + SCRYPT_N, + SCRYPT_R, + SCRYPT_P, + salt.toString("hex"), + derivedKey.toString("hex"), + ].join("$"); +} + +export async function verifyPassword( + password: string, + storedHash: string +): Promise { + const parts = storedHash.split("$"); + if (parts.length !== 6 || parts[0] !== "scrypt") return false; + const [, nRaw, rRaw, pRaw, saltHex, hashHex] = parts; + const n = Number(nRaw); + const r = Number(rRaw); + const p = Number(pRaw); + if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) { + return false; + } + const salt = Buffer.from(saltHex, "hex"); + const expected = Buffer.from(hashHex, "hex"); + const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p }); + if (derivedKey.length !== expected.length) return false; + return timingSafeEqual(derivedKey, expected); +} diff --git a/src/server/auth/sessions.ts b/src/server/auth/sessions.ts new file mode 100644 index 0000000..501f4c4 --- /dev/null +++ b/src/server/auth/sessions.ts @@ -0,0 +1,55 @@ +import { randomUUID } from "node:crypto"; + +interface Session { + id: string; + userId: string; + expiresAt: number; +} + +/** + * In-memory session store. The API runs as a single container/process, so a + * restart clearing every session is an acceptable trade-off for the + * simplicity of not needing a persisted session table. + */ +export class SessionStore { + private readonly sessions = new Map(); + + constructor(private readonly ttlMs: number) {} + + create(userId: string): Session { + const session: Session = { + id: randomUUID(), + userId, + expiresAt: Date.now() + this.ttlMs, + }; + this.sessions.set(session.id, session); + return session; + } + + /** Returns the session and slides its expiry forward, or undefined if gone/expired. */ + get(sessionId: string | undefined, slideExpiry = true): Session | undefined { + if (!sessionId) return undefined; + const session = this.sessions.get(sessionId); + if (!session) return undefined; + if (session.expiresAt < Date.now()) { + this.sessions.delete(sessionId); + return undefined; + } + if (slideExpiry) session.expiresAt = Date.now() + this.ttlMs; + return session; + } + + destroy(sessionId: string | undefined): void { + if (!sessionId) return; + this.sessions.delete(sessionId); + } + + /** Ends every other session for a user, e.g. after a password change. */ + destroyAllForUser(userId: string, exceptSessionId?: string): void { + for (const [id, session] of this.sessions) { + if (session.userId === userId && id !== exceptSessionId) { + this.sessions.delete(id); + } + } + } +} diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index 66fdd92..846a0da 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -16,6 +16,7 @@ import { RoomRepository } from "../../db/repositories/room.repository.js"; import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js"; import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js"; +import { UserRepository } from "../../db/repositories/user.repository.js"; export const circuitDeviceRowRepository = new CircuitDeviceRowRepository(db); @@ -39,3 +40,4 @@ export const externalCsvConfigurationRepository = new ExternalCsvConfigurationRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db); export const documentBrandingRepository = new DocumentBrandingRepository(db); +export const userRepository = new UserRepository(db); diff --git a/src/server/composition/auth.ts b/src/server/composition/auth.ts new file mode 100644 index 0000000..751f324 --- /dev/null +++ b/src/server/composition/auth.ts @@ -0,0 +1,8 @@ +import { readAuthConfig } from "../auth/auth-config.js"; +import { SessionStore } from "../auth/sessions.js"; +import { UserService } from "../../domain/services/user.service.js"; +import { userRepository } from "./application-repositories.js"; + +export const authConfig = readAuthConfig(); +export const sessionStore = new SessionStore(authConfig.sessionTtlMs); +export const userService = new UserService(userRepository, authConfig); diff --git a/src/server/controllers/auth.controller.ts b/src/server/controllers/auth.controller.ts new file mode 100644 index 0000000..9dabee5 --- /dev/null +++ b/src/server/controllers/auth.controller.ts @@ -0,0 +1,88 @@ +import type { Request, Response } from "express"; +import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js"; +import { InvalidCredentialsError, WrongPasswordError } from "../../domain/services/user.service.js"; +import { authConfig, sessionStore, userService } from "../composition/auth.js"; +import { + cookieOptions, + identityFromRequest, + sessionIdFromRequest, +} from "../auth/auth.middleware.js"; +import { SESSION_COOKIE } from "../auth/auth-config.js"; +import { userRepository } from "../composition/application-repositories.js"; + +export async function getAuthStatus(req: Request, res: Response) { + const identity = await identityFromRequest(req, sessionStore, userRepository); + const sessionId = sessionIdFromRequest(req); + if (identity && sessionId) { + res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig)); + } + res.json({ + signedIn: Boolean(identity), + mustChangePassword: identity?.mustChangePassword ?? false, + }); +} + +export async function login(req: Request, res: Response) { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + res.status(401).json({ error: "Anmeldung fehlgeschlagen." }); + return; + } + try { + const user = await userService.authenticate(parsed.data.username, parsed.data.password); + const session = sessionStore.create(user.id); + res.cookie(SESSION_COOKIE, session.id, cookieOptions(authConfig)); + res.json({ signedIn: true, mustChangePassword: user.mustChangePassword }); + } catch (error) { + if (error instanceof InvalidCredentialsError) { + res.status(401).json({ error: error.message }); + return; + } + throw error; + } +} + +export function getMe(req: Request, res: Response) { + const identity = req.authUser; + if (!identity) { + res.status(401).json({ error: "Nicht angemeldet." }); + return; + } + res.json(identity); +} + +export async function changePassword(req: Request, res: Response) { + const identity = req.authUser; + if (!identity) { + res.status(401).json({ error: "Nicht angemeldet." }); + return; + } + const parsed = changePasswordSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + try { + await userService.changeOwnPassword( + identity.id, + parsed.data.currentPassword, + parsed.data.newPassword + ); + } catch (error) { + if (error instanceof WrongPasswordError) { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } + // A password change invalidates every other session of this account; the + // current one stays so the change doesn't log the user out themselves. + sessionStore.destroyAllForUser(identity.id, sessionIdFromRequest(req)); + res.json({ changed: true }); +} + +export function logout(req: Request, res: Response) { + sessionStore.destroy(sessionIdFromRequest(req)); + res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig), maxAge: undefined }); + res.json({ signedOut: true }); +} diff --git a/src/server/controllers/user.controller.ts b/src/server/controllers/user.controller.ts new file mode 100644 index 0000000..0ad3cbc --- /dev/null +++ b/src/server/controllers/user.controller.ts @@ -0,0 +1,103 @@ +import type { Request, Response } from "express"; +import { + createUserSchema, + resetPasswordSchema, + updateUserSchema, +} from "../../shared/validation/user.schemas.js"; +import { UsernameTakenError } from "../../domain/services/user.service.js"; +import { userService } from "../composition/auth.js"; +import { userRepository } from "../composition/application-repositories.js"; + +// The password hash never leaves this layer. +function toDto(user: { + id: string; + username: string; + name: string; + role: string; + active: boolean; + mustChangePassword: boolean; + createdAt: Date; +}) { + return { + id: user.id, + username: user.username, + name: user.name, + role: user.role, + active: user.active, + mustChangePassword: user.mustChangePassword, + createdAt: user.createdAt, + }; +} + +export async function listUsers(_req: Request, res: Response) { + const rows = await userRepository.list(); + res.json(rows.map(toDto)); +} + +export async function createUser(req: Request, res: Response) { + const parsed = createUserSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + try { + const created = await userService.createUser(parsed.data); + if (!created) { + res.status(500).json({ error: "Benutzer konnte nicht angelegt werden." }); + return; + } + res.status(201).json(toDto(created)); + } catch (error) { + if (error instanceof UsernameTakenError) { + res.status(409).json({ error: error.message }); + return; + } + throw error; + } +} + +export async function updateUser(req: Request, res: Response) { + const { userId } = req.params; + if (typeof userId !== "string") { + res.status(400).json({ error: "Invalid userId" }); + return; + } + const parsed = updateUserSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + // The signed-in admin can't lock themselves out by deactivating or + // demoting their own account through this endpoint. + if (userId === req.authUser?.id) { + res.status(400).json({ error: "Der eigene Zugang kann hier nicht geändert werden." }); + return; + } + const existing = await userRepository.findById(userId); + if (!existing) { + res.status(404).json({ error: "Benutzer nicht gefunden." }); + return; + } + const updated = await userService.updateRoleAndActive(userId, parsed.data); + res.json(updated ? toDto(updated) : null); +} + +export async function resetUserPassword(req: Request, res: Response) { + const { userId } = req.params; + if (typeof userId !== "string") { + res.status(400).json({ error: "Invalid userId" }); + return; + } + const parsed = resetPasswordSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + const existing = await userRepository.findById(userId); + if (!existing) { + res.status(404).json({ error: "Benutzer nicht gefunden." }); + return; + } + const updated = await userService.resetPassword(userId, parsed.data.newPassword); + res.json(updated ? toDto(updated) : null); +} diff --git a/src/server/index.ts b/src/server/index.ts index 7cad7e3..868de79 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,12 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { documentBrandingRouter } from "./routes/document-branding.routes.js"; import { projectRouter } from "./routes/project.routes.js"; import { cableSizingRouter } from "./routes/cable-sizing.routes.js"; +import { authRouter } from "./routes/auth.routes.js"; +import { userRouter } from "./routes/user.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; +import { createAuthGuard, requirePasswordChanged } from "./auth/auth.middleware.js"; +import { authConfig, sessionStore, userService } from "./composition/auth.js"; +import { userRepository } from "./composition/application-repositories.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; import { closeDefaultDatabase } from "../db/client.js"; @@ -50,6 +55,15 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); +// Everything below /api requires a signed-in, active user, except +// /auth/login, /auth/status and /auth/logout (see createAuthGuard's +// openPaths). authRouter is mounted AFTER the guard (not before) so that +// /auth/me and /auth/change-password still get req.authUser populated by it. +app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository)); +app.use("/api", requirePasswordChanged()); + +app.use("/api/auth", authRouter); +app.use("/api/users", userRouter); app.use("/api/cable-sizing", cableSizingRouter); app.use("/api/projects", projectRouter); app.use("/api/global-devices", globalDeviceRouter); @@ -77,40 +91,48 @@ setInterval(() => { }); }, heartbeatIntervalMs).unref(); -const server = app.listen(port, () => { - logger.info("server started", { port }); -}); - -let shuttingDown = false; - -function shutdown(signal: NodeJS.Signals): void { - if (shuttingDown) return; - shuttingDown = true; - logger.info("shutting down", { signal }); - - // Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der - // Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten. - const forceExit = setTimeout(() => { - logger.error("shutdown timed out, forcing exit"); +userService + .ensureBootstrapAdmin() + .catch((error) => { + logger.error("bootstrap admin setup failed", toErrorMeta(error)); process.exit(1); - }, shutdownTimeoutMs); - forceExit.unref(); + }) + .then(() => { + const server = app.listen(port, () => { + logger.info("server started", { port }); + }); - server.close((error) => { - if (error) logger.error("http server close failed", toErrorMeta(error)); - try { - closeDefaultDatabase(); - } catch (closeError) { - logger.error("database close failed", toErrorMeta(closeError)); + let shuttingDown = false; + + function shutdown(signal: NodeJS.Signals): void { + if (shuttingDown) return; + shuttingDown = true; + logger.info("shutting down", { signal }); + + // Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der + // Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten. + const forceExit = setTimeout(() => { + logger.error("shutdown timed out, forcing exit"); + process.exit(1); + }, shutdownTimeoutMs); + forceExit.unref(); + + server.close((error) => { + if (error) logger.error("http server close failed", toErrorMeta(error)); + try { + closeDefaultDatabase(); + } catch (closeError) { + logger.error("database close failed", toErrorMeta(closeError)); + } + logger.info("shutdown complete"); + process.exit(error ? 1 : 0); + }); + + // Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des + // Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter. + server.closeIdleConnections(); } - logger.info("shutdown complete"); - process.exit(error ? 1 : 0); + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); }); - - // Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des - // Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter. - server.closeIdleConnections(); -} - -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); diff --git a/src/server/routes/auth.routes.ts b/src/server/routes/auth.routes.ts new file mode 100644 index 0000000..ee1cdd1 --- /dev/null +++ b/src/server/routes/auth.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { + changePassword, + getAuthStatus, + getMe, + login, + logout, +} from "../controllers/auth.controller.js"; + +export const authRouter = Router(); + +authRouter.get("/status", getAuthStatus); +authRouter.post("/login", login); +authRouter.get("/me", getMe); +authRouter.post("/change-password", changePassword); +authRouter.post("/logout", logout); diff --git a/src/server/routes/user.routes.ts b/src/server/routes/user.routes.ts new file mode 100644 index 0000000..a34699c --- /dev/null +++ b/src/server/routes/user.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { requireRole } from "../auth/auth.middleware.js"; +import { + createUser, + listUsers, + resetUserPassword, + updateUser, +} from "../controllers/user.controller.js"; + +export const userRouter = Router(); + +userRouter.use(requireRole("admin")); +userRouter.get("/", listUsers); +userRouter.post("/", createUser); +userRouter.patch("/:userId", updateUser); +userRouter.post("/:userId/reset-password", resetUserPassword); diff --git a/src/shared/constants/user-roles.ts b/src/shared/constants/user-roles.ts new file mode 100644 index 0000000..f123d5f --- /dev/null +++ b/src/shared/constants/user-roles.ts @@ -0,0 +1,7 @@ +export const USER_ROLES = ["admin", "user"] as const; +export type UserRole = (typeof USER_ROLES)[number]; + +export const USER_ROLE_LABEL: Record = { + admin: "Administrator", + user: "Benutzer", +}; diff --git a/src/shared/validation/auth.schemas.ts b/src/shared/validation/auth.schemas.ts new file mode 100644 index 0000000..544b052 --- /dev/null +++ b/src/shared/validation/auth.schemas.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const loginSchema = z + .object({ + username: z.string().trim().min(1), + password: z.string().min(1), + }) + .strict(); + +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(8).max(200), + }) + .strict(); diff --git a/src/shared/validation/user.schemas.ts b/src/shared/validation/user.schemas.ts new file mode 100644 index 0000000..ceb8961 --- /dev/null +++ b/src/shared/validation/user.schemas.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { USER_ROLES } from "../constants/user-roles.js"; + +const MIN_PASSWORD_LENGTH = 8; + +export const createUserSchema = z + .object({ + username: z.string().trim().min(1).max(200), + name: z.string().trim().min(1).max(200), + role: z.enum(USER_ROLES), + initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200), + }) + .strict(); + +export const updateUserSchema = z + .object({ + role: z.enum(USER_ROLES).optional(), + active: z.boolean().optional(), + }) + .strict() + .refine((value) => value.role !== undefined || value.active !== undefined, { + message: "Nothing to update.", + }); + +export const resetPasswordSchema = z + .object({ + newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200), + }) + .strict(); + +export type CreateUserInput = z.infer; +export type UpdateUserInput = z.infer; +export type ResetPasswordInput = z.infer;