diff --git a/README.md b/README.md index a74c73a..a4d8f3d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # gatekeeper-frontend -Web interface for [gatekeeper-mqtt](https://github.com/ComputerScienceHouse/gatekeeper-mqtt). Lets CSH members view door status and trigger unlocks from a browser. +Web interface for [gatekeeper-mqtt](https://github.com/ComputerScienceHouse/gatekeeper-mqtt). Lets CSH members view door status and trigger unlocks, and RTPs browse access logs, from a browser. 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 -- **CSH SSO auth** — login via CSH's OpenID Connect provider; tokens are refreshed automatically - **Access feedback** — door-specific error messages on 403 (e.g. safety seminar, RTP status) +### Logs +- **Access logs** — log viewer for door access events + ## Prerequisites - Node.js 20+ @@ -37,6 +39,8 @@ The OIDC client must have `http://localhost:3000/api/auth/callback/csh` in its a ## Development +Note: For developing new features or fixes, refer to the [gatekeeper-mqtt API docs](https://github.com/ComputerScienceHouse/gatekeeper-mqtt/blob/main/docs.org) for available endpoints. + ```bash npm run dev ``` diff --git a/app/doors/page.tsx b/app/doors/page.tsx index 1468083..ec86aac 100644 --- a/app/doors/page.tsx +++ b/app/doors/page.tsx @@ -10,17 +10,25 @@ import Spinner from "react-bootstrap/Spinner"; import { apiFetch } from "@/lib/api"; import { AUTH_PROVIDER_ID, REFRESH_TOKEN_ERROR } from "@/lib/constants"; import DoorCard, { type Door, type DoorStatus } from "@/components/DoorCard"; +import { useRouter } from "next/navigation"; export default function DoorsPage() { - const { data: session } = useSession({ - required: true, + const { data: session, status } = useSession({ + required: true, onUnauthenticated() { signIn(AUTH_PROVIDER_ID); }, }); + const router = useRouter(); const token = session?.accessToken ?? ""; const sessionError = session?.error; + useEffect(() => { + if (status !== "loading" && !session) { + router.replace("/unauthorized"); + } + }, [status, session, router]); + useEffect(() => { if (sessionError === REFRESH_TOKEN_ERROR) { signIn(AUTH_PROVIDER_ID); @@ -32,7 +40,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; @@ -136,6 +144,25 @@ export default function DoorsPage() { } }; + if (status === "loading" || !session) { + return ( + + + + ); + } + + const allowed = session?.groups?.includes("rtp"); + if (!allowed) { + return ( + +

Access Denied

+

You must be an RTP to view this page.

+
+ ); + } + + if (loadingDoors) { return ( diff --git a/app/logs/page.tsx b/app/logs/page.tsx new file mode 100644 index 0000000..9ab5b3e --- /dev/null +++ b/app/logs/page.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Container, Row, Col, Table, Spinner, Button } from "react-bootstrap"; +import Icon from "@mdi/react"; +import { mdiMagnify, mdiRefresh, mdiHistory, mdiCheckCircle, mdiCloseCircle, mdiEyeOutline } from "@mdi/js"; +import { apiFetch } from "@/lib/api"; + +import { AUTH_PROVIDER_ID, REFRESH_TOKEN_ERROR } from "@/lib/constants"; +import { useSession, signIn } from "next-auth/react"; + +interface LogEntry { + _id: string; + timestamp: string; + door: string; + doorName: string | null; + username: string | null; + name: string | null; + doorsId: string; + keyId: string; + granted: boolean; +} + +interface LogsResponse { + logs: LogEntry[]; + cursor: string | null; +} + + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString("en-US", { + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + }); +} + +async function fetchLogs(token: string, cursor?: string): Promise { + const path = cursor + ? `/admin/logs?cursor=${encodeURIComponent(cursor)}` : "/admin/logs"; + return apiFetch(path, token) as Promise; +} +export default function LogsPage() { + const { data: session, status } = useSession({ + required: true, + onUnauthenticated() { + signIn(AUTH_PROVIDER_ID); + }, + }); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [search, setSearch] = useState(""); + const [grantFilter, setGrantFilter] = useState<"all" | "granted" | "denied">("all"); + const [doorFilter, setDoorFilter] = useState("all"); + const [cursorStack, setCursorStack] = useState>([null]); + const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const token = session?.accessToken ?? ""; + const sessionError = session?.error; + + useEffect(() => { + if (sessionError === REFRESH_TOKEN_ERROR) { + signIn(AUTH_PROVIDER_ID); + } + }, [sessionError]); + const loadPage = useCallback(async (idx: number, cursor: string | null, silent = false) => { + if (!token) return; + silent ? setRefreshing(true) : setLoading(true); + try { + const data = await fetchLogs(token, cursor ?? undefined); + 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: any) { + console.error("Failed to load logs"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [token]); + + useEffect(() => { loadPage(0, null); }, [loadPage]); + if (status === "loading") { + return ( + + + + ); + } + + const allowed = session?.groups?.includes("rtp"); + if (!allowed) { + return ( + +

Access Denied

+

You must be an RTP to view this page.

+
+ ); + } + + const hasPrev = pageIndex > 0; + const hasNext = pageIndex + 1 < cursorStack.length || !!nextCursor; + const knownPages = cursorStack.length; + const currentPage = pageIndex + 1; + + const goPrev = () => hasPrev && loadPage(pageIndex - 1, cursorStack[pageIndex - 1]); + const goNext = () => { + if (!hasNext) return; + const nextIdx = pageIndex + 1; + loadPage(nextIdx, cursorStack[nextIdx] ?? nextCursor); + }; + + + const uniqueDoors = Array.from(new Set(logs.map((l) => l.doorName ?? l.door))).sort(); + + const filtered = logs.filter((l) => { + const q = search.toLowerCase(); + const matchSearch = + q === "" || + (l.doorName ?? l.door).toLowerCase().includes(q) || + (l.username ?? "").toLowerCase().includes(q) || + (l.name ?? "").toLowerCase().includes(q); + const matchGrant = + grantFilter === "all" || + (grantFilter === "granted" && l.granted) || + (grantFilter === "denied" && !l.granted); + const matchDoor = + doorFilter === "all" || (l.doorName ?? l.door) === doorFilter; + return matchSearch && matchGrant && matchDoor; + }); + + return ( + + + + +

+ Access Logs +

+

+ Big Brother is watching +

+ + + + + +
+ +
+
+
+
+ + + +
+ setSearch(e.target.value)} + /> +
+
+
+ +
+
+ +
+ {(search || grantFilter !== "all" || doorFilter !== "all") && ( +
+ +
+ )} +
+ +
+
+ + + Events + +
+ + {loading ? ( +
+ + Loading +
+ ) : filtered.length === 0 ? ( +
+ +

No log entries match your filters.

+ {(search || grantFilter !== "all" || doorFilter !== "all") && ( + + )} +
+ ) : ( +
+ + + + + + + + + + + + {filtered.map((entry) => ( + + + + + + + + ))} + +
TimestampDoorUsernameNameAccess
+ {formatTimestamp(entry.timestamp)} + + {entry.doorName ?? ( + {entry.door} + )} + + {entry.username ?? ( + unknown + )} + + {entry.name ?? ( + + )} + + + + {entry.granted ? "Granted" : "Denied"} + +
+
+ + )} + +
+ + Page {currentPage}  ·  {filtered.length} entr{filtered.length !== 1 ? "ies" : "y"} + {filtered.length !== logs.length && ` (filtered from ${logs.length})`} + + +
+
+
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx index 37a4320..df2a42e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function NotFound() { - redirect("/doors"); + redirect("/logs"); } diff --git a/app/page.tsx b/app/page.tsx index c411fc1..70300f3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function Home() { - redirect("/doors"); + redirect("/logs"); } diff --git a/components/AppNavbar.tsx b/components/AppNavbar.tsx index f0d41e7..86a8a3c 100644 --- a/components/AppNavbar.tsx +++ b/components/AppNavbar.tsx @@ -1,40 +1,109 @@ "use client"; +import { useState } from "react"; import { useSession, signOut } from "next-auth/react"; -import Navbar from "react-bootstrap/Navbar"; -import Container from "react-bootstrap/Container"; -import NavDropdown from "react-bootstrap/NavDropdown"; export default function AppNavbar() { const { data: session } = useSession(); - const username = session?.username; + const username = session?.username as string | undefined; + const displayName = session?.user?.name ?? username; + const idToken = session?.idToken; + + const [collapsed, setCollapsed] = useState(true); return ( - - - Gatekeeper - {username && ( - - {username} - {session?.user?.name ?? username} - - } - > - signOut()}> - Sign out - - - )} - - + ); } + +function UserDropdown({ + username, + displayName, + idToken, +}: { + username: string; + displayName?: string | null; + idToken?: string; +}) { + const [open, setOpen] = useState(false); + + return ( +
  • + { e.preventDefault(); setOpen((o) => !o); }} + aria-expanded={open} + > + {username} + {displayName ?? username} + + + +
  • + ); +} \ No newline at end of file diff --git a/lib/api.ts b/lib/api.ts index c6c1ba3..5b6e59c 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -13,7 +13,7 @@ export async function apiFetch( }, }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); if (res.status === 204) return null; + if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } diff --git a/lib/auth.ts b/lib/auth.ts index dee7854..a382f64 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -52,16 +52,21 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, callbacks: { jwt({ token, account, profile }) { - if (account) { - return { - ...token, - accessToken: account.access_token, - refreshToken: account.refresh_token, - expiresAt: account.expires_at, - username: (profile as Record)?.preferred_username as string | undefined, - error: undefined, - }; - } + if (account) { + const payload = JSON.parse( + Buffer.from((account as any).access_token.split(".")[1], "base64").toString() + ); + return { + ...token, + accessToken: account.access_token, + refreshToken: account.refresh_token, + idToken: account.id_token, + expiresAt: account.expires_at, + username: (profile as any)?.preferred_username, + groups: payload.groups ?? [], + error: undefined, + }; + } if (token.expiresAt && Date.now() < token.expiresAt * 1000) { return token; @@ -73,6 +78,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ session.accessToken = token.accessToken as string; session.error = token.error as string | undefined; session.username = token.username; + session.groups = token.groups ?? []; + session.idToken = token.idToken as string; return session; }, }, diff --git a/lib/constants.ts b/lib/constants.ts index 8629916..f77ca1e 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -5,8 +5,8 @@ export const DEFAULT_NO_ACCESS = "You do not have access to this door."; // Door-specific messages, keyed by door name export const DOOR_ACCESS_DENIED_MESSAGES: Record = { - "Project Room": "Have you completed the required safety seminar?", - "Server Room": "Server Room access requires being an active RTP.", + "Project Room": "Safety Seminar Required", + "Server Room": "Restricted", }; // Auth error messages shown on /auth-error, keyed by next-auth error code diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index 7e0aa17..fb9426c 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -4,17 +4,21 @@ import "next-auth/jwt"; declare module "next-auth" { interface Session { accessToken: string; + idToken?: string; error?: string; username?: string; + groups: string[]; } } declare module "next-auth/jwt" { interface JWT { accessToken?: string; + idToken?: string; refreshToken?: string; expiresAt?: number; error?: string; username?: string; + groups: string[]; } }