diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7511bff --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +.next +out +build +pnpm-lock.yaml \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..63c660c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "es5" +} diff --git a/README.md b/README.md index b493f15..c95204d 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,27 @@ Web interface for [gatekeeper-mqtt](https://github.com/ComputerScienceHouse/gate Built with Next.js 15, next-auth v5 (CSH SSO), react-bootstrap, and [csh-material-bootstrap](https://github.com/ComputerScienceHouse/csh-material-bootstrap). ## Features + ### Doors + - **Doors dashboard** — live online/offline status for all doors, updated every 30 seconds - **Unlock** — send an unlock command to any door with a single click - **Access feedback** — door-specific error messages on 403 (e.g. safety seminar, RTP status) ### Logs + - **Access logs** — log viewer for door access events ### Keys + - **Keys Management** — Disable/Delete user keys using a simple lookup ### AccessGate + - Enforce RTPs to state a reason in order to access Keys/Logs page ### Audit + - **Audit Logs** — log viewer for page access events ## Prerequisites @@ -37,12 +43,12 @@ cp .env.local.example .env.local Edit `.env.local`: -| Variable | Description | -|----------|-------------| +| Variable | Description | +| --------------------- | ------------------------------------------------------------------------------------- | | `NEXT_PUBLIC_API_URL` | Base URL of the gatekeeper-mqtt API, no trailing slash (e.g. `http://localhost:3001`) | -| `AUTH_SECRET` | Session encryption secret — generate with `openssl rand -base64 32` | -| `AUTH_OIDC_ID` | OIDC client ID from CSH SSO | -| `AUTH_OIDC_SECRET` | OIDC client secret from CSH SSO | +| `AUTH_SECRET` | Session encryption secret — generate with `openssl rand -base64 32` | +| `AUTH_OIDC_ID` | OIDC client ID from CSH SSO | +| `AUTH_OIDC_SECRET` | OIDC client secret from CSH SSO | The OIDC client must have `http://localhost:3000/api/auth/callback/csh` in its allowed redirect URIs (replace `localhost:3000` with your deployment URL in production). @@ -68,4 +74,3 @@ npm test npm run build npm start ``` - diff --git a/app/audit/page.tsx b/app/audit/page.tsx index e5121de..7bb74f0 100644 --- a/app/audit/page.tsx +++ b/app/audit/page.tsx @@ -34,7 +34,11 @@ function formatTimestamp(iso: string): string { }); } -async function fetchAuditEntries(token: string, cursor?: string, search?: string): Promise { +async function fetchAuditEntries( + token: string, + cursor?: string, + search?: string +): Promise { const params = new URLSearchParams(); if (cursor) params.set("cursor", cursor); if (search) params.set("search", search); @@ -43,13 +47,13 @@ async function fetchAuditEntries(token: string, cursor?: string, search?: string function AuditPageInner() { const { data: session } = useSession(); - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(""); - const [searchInput, setSearchInput] = useState(""); - const [cursorStack, setCursorStack] = useState>([null]); - const [nextCursor, setNextCursor] = useState(null); - const [pageIndex, setPageIndex] = useState(0); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [cursorStack, setCursorStack] = useState>([null]); + const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(0); const token = session?.accessToken ?? ""; const sessionError = session?.error; @@ -62,27 +66,34 @@ function AuditPageInner() { return () => clearTimeout(t); }, [searchInput]); - const loadPage = useCallback(async (idx: number, cursor: string | null) => { - if (!token) return; - setLoading(true); - try { - const data = await fetchAuditEntries(token, cursor ?? undefined, search); - setEntries(data.entries); - setNextCursor(data.cursor); - setPageIndex(idx); - setCursorStack((prev) => { - if (idx + 1 < prev.length) return prev; - if (!data.cursor) return prev; - const next = [...prev]; - next[idx + 1] = data.cursor; - return next; - }); - } catch (err) { - console.error("Failed to load audit entries"); - } finally { - setLoading(false); - } - }, [token, search]); + const loadPage = useCallback( + async (idx: number, cursor: string | null) => { + if (!token) return; + setLoading(true); + try { + const data = await fetchAuditEntries( + token, + cursor ?? undefined, + search + ); + setEntries(data.entries); + setNextCursor(data.cursor); + setPageIndex(idx); + setCursorStack((prev) => { + if (idx + 1 < prev.length) return prev; + if (!data.cursor) return prev; + const next = [...prev]; + next[idx + 1] = data.cursor; + return next; + }); + } catch (err) { + console.error("Failed to load audit entries"); + } finally { + setLoading(false); + } + }, + [token, search] + ); useEffect(() => { setCursorStack([null]); @@ -94,7 +105,8 @@ function AuditPageInner() { const hasPrev = pageIndex > 0; const hasNext = nextCursor !== null; - const goPrev = () => hasPrev && loadPage(pageIndex - 1, cursorStack[pageIndex - 1]); + const goPrev = () => + hasPrev && loadPage(pageIndex - 1, cursorStack[pageIndex - 1]); const goNext = () => { if (!hasNext) return; const nextIdx = pageIndex + 1; @@ -104,10 +116,28 @@ function AuditPageInner() { const PaginationControls = () => ( ); @@ -117,7 +147,9 @@ function AuditPageInner() {
- + + +
- Audit Logs + + + Audit Logs +
@@ -143,17 +178,32 @@ function AuditPageInner() {
) : entries.length === 0 ? (
- +

No entries match your filters.

{search && ( - )}
) : (
- +
@@ -166,10 +216,14 @@ function AuditPageInner() { {entries.map((entry) => ( - + - + ))} @@ -182,9 +236,7 @@ function AuditPageInner() { className="card-footer d-grid align-items-center" style={{ gridTemplateColumns: "1fr auto 1fr" }} > - - Page {pageIndex + 1}   - + Page {pageIndex + 1}  
@@ -201,4 +253,4 @@ export default function AuditPage() { ); -} \ No newline at end of file +} diff --git a/app/auth-error/page.tsx b/app/auth-error/page.tsx index 10e35ba..0132efc 100644 --- a/app/auth-error/page.tsx +++ b/app/auth-error/page.tsx @@ -10,7 +10,9 @@ function AuthErrorContent() { const params = useSearchParams(); const error = params.get("error") ?? "Unknown"; const description = params.get("error_description"); - const message = AUTH_ERROR_MESSAGES[error] ?? `An authentication error occurred (${error}).`; + const message = + AUTH_ERROR_MESSAGES[error] ?? + `An authentication error occurred (${error}).`; return ( diff --git a/app/doors/page.tsx b/app/doors/page.tsx index dfa72b6..69fcdaf 100644 --- a/app/doors/page.tsx +++ b/app/doors/page.tsx @@ -9,8 +9,9 @@ import Col from "react-bootstrap/Col"; import Spinner from "react-bootstrap/Spinner"; import { apiFetch } from "@/lib/api"; import DoorCard, { type Door, type DoorStatus } from "@/components/DoorCard"; +import AuthGate from "@/components/AuthGate"; -export default function DoorsPage() { +function DoorsPageInner() { const { data: session } = useSession(); const token = session?.accessToken ?? ""; @@ -19,7 +20,7 @@ export default function DoorsPage() { const [loadingDoors, setLoadingDoors] = useState(true); const [fetchError, setFetchError] = useState(null); const [unlocking, setUnlocking] = useState>({}); - + // Stable ref so fetchStatuses doesn't change identity on token refresh const tokenRef = useRef(token); tokenRef.current = token; @@ -44,7 +45,10 @@ export default function DoorsPage() { const r = results[i]; if (r.status === "fulfilled" && r.value) { const s = r.value as DoorStatus; - if (prev[d.id]?.guess !== s.guess || prev[d.id]?.lastHeartbeat !== s.lastHeartbeat) { + if ( + prev[d.id]?.guess !== s.guess || + prev[d.id]?.lastHeartbeat !== s.lastHeartbeat + ) { next[d.id] = s; changed = true; } @@ -152,6 +156,7 @@ export default function DoorsPage() { status={statuses[door.id]} onUnlock={handleUnlock} loading={unlocking[door.id] ?? false} + offline={statuses[door.id]?.guess === "offline"} /> ))} @@ -167,6 +172,7 @@ export default function DoorsPage() { status={statuses[door.id]} onUnlock={handleUnlock} loading={false} + offline={statuses[door.id]?.guess === "offline"} /> ))} @@ -175,4 +181,12 @@ export default function DoorsPage() { )} ); -} \ No newline at end of file +} + +export default function DoorsPage() { + return ( + + + + ); +} diff --git a/app/keys/page.tsx b/app/keys/page.tsx index e87f5e4..ce2d90c 100644 --- a/app/keys/page.tsx +++ b/app/keys/page.tsx @@ -1,20 +1,14 @@ "use client"; -import {useState, useCallback, useEffect} from "react"; -import { - Container, - Row, - Col, - Table, - Button, -} from "react-bootstrap"; +import { useState, useCallback, useEffect } from "react"; +import { Container, Row, Col, Table, Button } from "react-bootstrap"; import Icon from "@mdi/react"; import { mdiMagnify, mdiKeyVariant, mdiTrashCanOutline, mdiAccountKey, -} from "@mdi/js"; +} from "@mdi/js"; import Swal from "sweetalert2"; import { apiFetch } from "@/lib/api"; import { useSession } from "next-auth/react"; @@ -43,7 +37,7 @@ function parseCn(dn: string): string | null { } function KeysPageInner() { - const {data: session} = useSession(); + const { data: session } = useSession(); const [granted, setGranted] = useState(false); const [username, setUsername] = useState(""); @@ -70,9 +64,9 @@ function KeysPageInner() { token )) as Key[]; setKeys(keysRes); - } catch (e) { - console.error(e); - } + } catch (e) { + console.error(e); + } } catch (e) { console.error(e); } @@ -86,7 +80,9 @@ function KeysPageInner() { method: "PATCH", body: JSON.stringify({ enabled, username: user?.username }), }); - setKeys((prev) => prev.map((k) => (k._id === keyId ? { ...k, enabled } : k))); + setKeys((prev) => + prev.map((k) => (k._id === keyId ? { ...k, enabled } : k)) + ); } catch (e) { console.error(e); } finally { @@ -162,29 +158,29 @@ function KeysPageInner() { {user && ( <>
-
- - - Groups - -
-
-
- {user.groups.filter((g) => parseCn(g)).length === 0 ? ( - No groups - ) : ( - user.groups.map((g) => { - const label = parseCn(g); - if (!label) return null; - return ( - - {label} - - ); - }) - )} -
+
+ + + Groups + +
+
+
+ {user.groups.filter((g) => parseCn(g)).length === 0 ? ( + No groups + ) : ( + user.groups.map((g) => { + const label = parseCn(g); + if (!label) return null; + return ( + + {label} + + ); + }) + )}
+
@@ -201,61 +197,80 @@ function KeysPageInner() {
) : (
-
Timestamp
{formatTimestamp(entry.timestamp)} + {formatTimestamp(entry.timestamp)} + {entry.username} {entry.name}{entry.action} + {entry.action} + {entry.reason}
- - - - - - - - - - {keys.map((k) => ( - - - - - - +
UIDKeyIdRealmsStatus -
{k.uid ?? Mobile key}{k._id} -
- {Object.keys(k) - .filter((r) => r.endsWith("Id") && !["_id", "userId"].includes(r) && k[r as keyof Key]) //this will be huge for debugging new realms - .map((r) => ( -
- {r}: {k[r as keyof Key] as string} -
- ))} -
-
- - {k.enabled ? "Enabled" : "Disabled"} - - -
- - -
-
+ + + + + + + - ))} - -
UIDKeyIdRealms + Status +
+ + + {keys.map((k) => ( + + {k.uid ?? Mobile key} + {k._id} + +
+ {Object.keys(k) + .filter( + (r) => + r.endsWith("Id") && + !["_id", "userId"].includes(r) && + k[r as keyof Key] + ) //this will be huge for debugging new realms + .map((r) => ( +
+ + {r}: {k[r as keyof Key] as string} + +
+ ))} +
+ + + + {k.enabled ? "Enabled" : "Disabled"} + + + +
+ + +
+ + + ))} + +
)}
@@ -271,4 +286,4 @@ export default function KeysPage() { ); -} \ No newline at end of file +} diff --git a/app/logs/page.tsx b/app/logs/page.tsx index c108a33..8671302 100644 --- a/app/logs/page.tsx +++ b/app/logs/page.tsx @@ -3,7 +3,13 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { Container, Row, Col, Table, Spinner, Button } from "react-bootstrap"; import Icon from "@mdi/react"; -import { mdiMagnify, mdiHistory, mdiCheckCircle, mdiCloseCircle, mdiEyeOutline } from "@mdi/js"; +import { + mdiMagnify, + mdiHistory, + mdiCheckCircle, + mdiCloseCircle, + mdiEyeOutline, +} from "@mdi/js"; import { apiFetch } from "@/lib/api"; import { AUTH_PROVIDER_ID, REFRESH_TOKEN_ERROR } from "@/lib/constants"; @@ -24,6 +30,7 @@ interface LogEntry { keyId: string; uid?: string | null; granted: boolean; + accessType: "oidc" | "mobile" | "physical"; } interface LogsResponse { @@ -99,19 +106,21 @@ async function fetchDoors(token: string): Promise { function LogsPageInner() { const { data: session } = useSession(); const [granted, setGranted] = useState(false); - const [logs, setLogs] = useState([]); - const [doors, setDoors] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [search, setSearch] = useState(""); + const [logs, setLogs] = useState([]); + const [doors, setDoors] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [search, setSearch] = useState(""); const [searchInput, setSearchInput] = useState(""); - const [grantFilter, setGrantFilter] = useState<"all" | "granted" | "denied">("all"); - const [doorFilter, setDoorFilter] = useState("all"); - const [sinceDate, setSinceDate] = useState(defaultSinceDate()); - const [untilDate, setUntilDate] = useState(defaultUntilDate()); + const [grantFilter, setGrantFilter] = useState<"all" | "granted" | "denied">( + "all" + ); + const [doorFilter, setDoorFilter] = useState("all"); + const [sinceDate, setSinceDate] = useState(defaultSinceDate()); + const [untilDate, setUntilDate] = useState(defaultUntilDate()); const [cursorStack, setCursorStack] = useState>([null]); - const [nextCursor, setNextCursor] = useState(null); - const [pageIndex, setPageIndex] = useState(0); + const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(0); const token = session?.accessToken ?? ""; const sessionError = session?.error; const rangeElRef = useRef(null); @@ -130,31 +139,33 @@ function LogsPageInner() { let cancelled = false; - function handleChange() { - const rangepicker = rangepickerRef.current; - if (!rangepicker) return; - const dates = rangepicker.getDates(DATE_FORMAT); - const start = dates[0] as string | undefined; - const end = dates[1] as string | undefined; - if (start) setSinceDate(start); - if (end) setUntilDate(end); - } - - import("vanillajs-datepicker/DateRangePicker").then(({ default: DateRangePickerCtor }) => { - if (cancelled || !rangeElRef.current) return; - - const rangepicker = new DateRangePickerCtor(rangeElRef.current, { - format: DATE_FORMAT, - buttonClass: "btn", - autohide: true, - }); - rangepickerRef.current = rangepicker; - rangeElRef.current.addEventListener("changeDate", handleChange); + function handleChange() { + const rangepicker = rangepickerRef.current; + if (!rangepicker) return; + const dates = rangepicker.getDates(DATE_FORMAT); + const start = dates[0] as string | undefined; + const end = dates[1] as string | undefined; + if (start) setSinceDate(start); + if (end) setUntilDate(end); + } - if (startInputRef.current) startInputRef.current.value = sinceDate; - if (endInputRef.current) endInputRef.current.value = untilDate; - rangepicker.setDates(sinceDate, untilDate); - }); + import("vanillajs-datepicker/DateRangePicker").then( + ({ default: DateRangePickerCtor }) => { + if (cancelled || !rangeElRef.current) return; + + const rangepicker = new DateRangePickerCtor(rangeElRef.current, { + format: DATE_FORMAT, + buttonClass: "btn", + autohide: true, + }); + rangepickerRef.current = rangepicker; + rangeElRef.current.addEventListener("changeDate", handleChange); + + if (startInputRef.current) startInputRef.current.value = sinceDate; + if (endInputRef.current) endInputRef.current.value = untilDate; + rangepicker.setDates(sinceDate, untilDate); + } + ); return () => { cancelled = true; @@ -162,7 +173,6 @@ function LogsPageInner() { rangepickerRef.current?.destroy(); rangepickerRef.current = null; }; - }, [granted]); useEffect(() => { @@ -171,33 +181,46 @@ function LogsPageInner() { useEffect(() => { if (!granted || !token) return; - fetchDoors(token).then(setDoors).catch((e) => console.error(e)); + fetchDoors(token) + .then(setDoors) + .catch((e) => console.error(e)); }, [granted, token]); - const loadPage = useCallback(async (idx: number, cursor: string | null, silent = false) => { - if (!token) return; - silent ? setRefreshing(true) : setLoading(true); - try { - const since = toIso(sinceDate, false); - const until = toIso(untilDate, true); - const data = await fetchLogs(token, cursor ?? undefined, since, until, search, doorFilter, grantFilter); - setLogs(data.logs); - setNextCursor(data.cursor); - setPageIndex(idx); - setCursorStack((prev) => { - if (idx + 1 < prev.length) return prev; - if (!data.cursor) return prev; - const next = [...prev]; - next[idx + 1] = data.cursor; - return next; - }); - } catch (err) { - console.error("Failed to load logs"); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [token, sinceDate, untilDate, search, doorFilter, grantFilter]); + const loadPage = useCallback( + async (idx: number, cursor: string | null, silent = false) => { + if (!token) return; + silent ? setRefreshing(true) : setLoading(true); + try { + const since = toIso(sinceDate, false); + const until = toIso(untilDate, true); + const data = await fetchLogs( + token, + cursor ?? undefined, + since, + until, + search, + doorFilter, + grantFilter + ); + setLogs(data.logs); + setNextCursor(data.cursor); + setPageIndex(idx); + setCursorStack((prev) => { + if (idx + 1 < prev.length) return prev; + if (!data.cursor) return prev; + const next = [...prev]; + next[idx + 1] = data.cursor; + return next; + }); + } catch (err) { + console.error("Failed to load logs"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [token, sinceDate, untilDate, search, doorFilter, grantFilter] + ); // reset to page 0 and refetch whenever any filter changes useEffect(() => { @@ -211,7 +234,8 @@ function LogsPageInner() { const hasPrev = pageIndex > 0; const hasNext = nextCursor !== null; - const goPrev = () => hasPrev && loadPage(pageIndex - 1, cursorStack[pageIndex - 1]); + const goPrev = () => + hasPrev && loadPage(pageIndex - 1, cursorStack[pageIndex - 1]); const goNext = () => { if (!hasNext) return; const nextIdx = pageIndex + 1; @@ -221,10 +245,28 @@ function LogsPageInner() { const PaginationControls = () => ( ); @@ -241,7 +283,6 @@ function LogsPageInner() { return ( - @@ -258,7 +303,9 @@ function LogsPageInner() {
- + + +
- + setGrantFilter(e.target.value as typeof grantFilter) + } + >
- setDoorFilter(e.target.value)} + > - {doors.map((d) => )} + {doors.map((d) => ( + + ))}
@@ -304,7 +365,10 @@ function LogsPageInner() {
- Logs + + + Logs +
@@ -317,7 +381,11 @@ function LogsPageInner() {
) : logs.length === 0 ? (
- +

No log entries match your filters.

{(search || grantFilter !== "all" || doorFilter !== "all") && (