From 5e9b6556d65c528beca26d2f9d760641a078bdaf Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sat, 25 Jul 2026 22:13:28 -0400 Subject: [PATCH 1/6] feat: authgate --- components/AuthGate.tsx | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 components/AuthGate.tsx diff --git a/components/AuthGate.tsx b/components/AuthGate.tsx new file mode 100644 index 0000000..3614f38 --- /dev/null +++ b/components/AuthGate.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Container, Spinner } from "react-bootstrap"; +import { useSession, signIn } from "next-auth/react"; +import { AUTH_PROVIDER_ID, REFRESH_TOKEN_ERROR } from "@/lib/constants"; +import { useEffect } from "react"; + +export default function AuthGate({ children }) { + const { data: session, status } = useSession({ + required: true, + onUnauthenticated() { + signIn(AUTH_PROVIDER_ID); + }, + }); + + const sessionError = session?.error; + useEffect(() => { + if (sessionError === REFRESH_TOKEN_ERROR) signIn(AUTH_PROVIDER_ID); + }, [sessionError]); + + if (status === "loading") { + return ( + + + + ); + } + + if (!session?.groups?.includes("rtp")) { + return ( + +

Access Denied

+

You must be an RTP to view this page.

+
+ ); + } + + return children; +} \ No newline at end of file From ab0e354b1bb47b395ab3c1df26f74bfd3dcd67de Mon Sep 17 00:00:00 2001 From: Arnav Gawas Date: Sat, 25 Jul 2026 22:14:11 -0400 Subject: [PATCH 2/6] feat: access, git history is ducked cus i accidently nuked stuff --- app/doors/page.tsx | 46 +------ app/home/page.tsx | 37 +++++ app/keys/page.tsx | 274 ++++++++++++++++++++++++++++++++++++++ app/logs/page.tsx | 169 ++++++++++++----------- components/AccessGate.tsx | 69 ++++++++++ components/AppNavbar.tsx | 99 ++++++++------ 6 files changed, 533 insertions(+), 161 deletions(-) create mode 100644 app/home/page.tsx create mode 100644 app/keys/page.tsx create mode 100644 components/AccessGate.tsx diff --git a/app/doors/page.tsx b/app/doors/page.tsx index ec86aac..dfa72b6 100644 --- a/app/doors/page.tsx +++ b/app/doors/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useSession, signIn } from "next-auth/react"; +import { useSession } from "next-auth/react"; import { useEffect, useState, useCallback, useRef, useMemo } from "react"; import { toast } from "react-toastify"; import Container from "react-bootstrap/Container"; @@ -8,32 +8,11 @@ import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; 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, status } = useSession({ - required: true, - onUnauthenticated() { - signIn(AUTH_PROVIDER_ID); - }, - }); - const router = useRouter(); + const { data: session } = useSession(); 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); - } - }, [sessionError]); const [doors, setDoors] = useState([]); const [statuses, setStatuses] = useState>({}); @@ -144,25 +123,6 @@ 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 ( @@ -215,4 +175,4 @@ export default function DoorsPage() { )} ); -} +} \ No newline at end of file diff --git a/app/home/page.tsx b/app/home/page.tsx new file mode 100644 index 0000000..a164e50 --- /dev/null +++ b/app/home/page.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; +import Icon from "@mdi/react"; +import { mdiKeyVariant, mdiDatabase } from "@mdi/js"; + +const sections = [ + { + href: "/keys", + title: "Keys", + icon: mdiKeyVariant, + }, + { + href: "/logs", + title: "Logs", + icon: mdiDatabase, + }, +]; + +export default function HomePage() { + return ( +
+
+ {sections.map((section) => ( +
+ +
+
+ +
{section.title}
+
+
+ +
+ ))} +
+
+ ); +} diff --git a/app/keys/page.tsx b/app/keys/page.tsx new file mode 100644 index 0000000..e87f5e4 --- /dev/null +++ b/app/keys/page.tsx @@ -0,0 +1,274 @@ +"use client"; + +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"; +import Swal from "sweetalert2"; +import { apiFetch } from "@/lib/api"; +import { useSession } from "next-auth/react"; +import AuthGate from "@/components/AuthGate"; +import AccessGate from "@/components/AccessGate"; + +interface UserInfo { + id: string; + username: string; + disabled: boolean; + groups: string[]; +} + +interface Key { + _id: string; + uid?: string; + enabled: boolean; + doorsId?: string; + drinkId?: string; + memberProjectsId?: string; +} + +function parseCn(dn: string): string | null { + const match = dn.match(/^cn=([^,]+),cn=groups,cn=accounts/i); + return match ? match[1] : null; +} + +function KeysPageInner() { + const {data: session} = useSession(); + + const [granted, setGranted] = useState(false); + const [username, setUsername] = useState(""); + const [user, setUser] = useState(null); + const [keys, setKeys] = useState([]); + const [mutating, setMutating] = useState(false); + + const token = session?.accessToken ?? ""; + + const lookup = useCallback(async () => { + if (!token || !username) return; + setUser(null); + setKeys([]); + try { + const userRes = (await apiFetch( + `/admin/users/uuid-by-uid/${username}`, + token + )) as UserInfo; + setUser({ ...userRes, username: username }); + + try { + const keysRes = (await apiFetch( + `/admin/keys/by-user?userId=${userRes.id}`, + token + )) as Key[]; + setKeys(keysRes); + } catch (e) { + console.error(e); + } + } catch (e) { + console.error(e); + } + }, [token, username]); + + async function toggleKey(keyId: string, enabled: boolean) { + if (!token) return; + setMutating(true); + try { + await apiFetch(`/admin/keys/${keyId}`, token, { + method: "PATCH", + body: JSON.stringify({ enabled, username: user?.username }), + }); + setKeys((prev) => prev.map((k) => (k._id === keyId ? { ...k, enabled } : k))); + } catch (e) { + console.error(e); + } finally { + setMutating(false); + } + } + + async function removeKey(keyId: string) { + if (!token) return; + setMutating(true); + try { + await apiFetch(`/admin/keys/${keyId}`, token, { + method: "DELETE", + body: JSON.stringify({ username: user?.username }), + }); + setKeys((prev) => prev.filter((k) => k._id !== keyId)); + } catch (e) { + console.error(e); + } finally { + setMutating(false); + } + } + + async function confirmAndRemoveKey(keyId: string) { + const result = await Swal.fire({ + title: "Delete this key?", + theme: "bootstrap-5", + icon: "warning", + text: `This will permanently remove this key from ${user?.username}. This can't be undone.`, + showCloseButton: true, + showCancelButton: true, + reverseButtons: true, + customClass: { + cancelButton: "btn btn-primary", + confirmButton: "btn btn-danger", + }, + }); + if (result.isConfirmed) { + await removeKey(keyId); + } + } + + if (!granted) { + return ( + setGranted(true)} + /> + ); + } + + return ( + +
+
+
+ + + + setUsername(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && lookup()} + /> +
+
+
+ + {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} + + ); + }) + )} +
+
+
+ +
+
+ + + Keys + +
+ + {keys.length === 0 ? ( +
+

No keys registered

+
+ ) : ( +
+ + + + + + + + + + + {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"} + + +
+ + +
+
+
+ )} +
+ + )} +
+ ); +} + +export default function KeysPage() { + return ( + + + + ); +} \ No newline at end of file diff --git a/app/logs/page.tsx b/app/logs/page.tsx index 26b1189..c108a33 100644 --- a/app/logs/page.tsx +++ b/app/logs/page.tsx @@ -10,6 +10,8 @@ import { AUTH_PROVIDER_ID, REFRESH_TOKEN_ERROR } from "@/lib/constants"; import { useSession, signIn } from "next-auth/react"; import type DateRangePicker from "vanillajs-datepicker/DateRangePicker"; import "vanillajs-datepicker/css/datepicker-bs5.css"; +import AuthGate from "@/components/AuthGate"; +import AccessGate from "@/components/AccessGate"; interface LogEntry { _id: string; @@ -20,6 +22,7 @@ interface LogEntry { name: string | null; doorsId: string; keyId: string; + uid?: string | null; granted: boolean; } @@ -70,23 +73,38 @@ function formatTimestamp(iso: string): string { }); } -async function fetchLogs(token: string, cursor?: string, since?: string, until?: string): Promise { +async function fetchLogs( + token: string, + cursor?: string, + since?: string, + until?: string, + search?: string, + door?: string, + granted?: string +): Promise { const params = new URLSearchParams(); if (cursor) params.set("cursor", cursor); - if (since) params.set("since", since); - if (until) params.set("until", until); - console.log("fetchLogs params:", params.toString()); + if (since) params.set("since", since); + if (until) params.set("until", until); + if (search) params.set("search", search); + if (door && door !== "all") params.set("door", door); + if (granted && granted !== "all") params.set("granted", granted); return apiFetch(`/admin/logs?${params}`, token) as Promise; } -export default function LogsPage() { - const { data: session, status } = useSession({ - required: true, - onUnauthenticated() { signIn(AUTH_PROVIDER_ID); }, - }); + +async function fetchDoors(token: string): Promise { + return apiFetch(`/admin/logs/doors`, token) as 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 [searchInput, setSearchInput] = useState(""); const [grantFilter, setGrantFilter] = useState<"all" | "granted" | "denied">("all"); const [doorFilter, setDoorFilter] = useState("all"); const [sinceDate, setSinceDate] = useState(defaultSinceDate()); @@ -101,8 +119,14 @@ export default function LogsPage() { const endInputRef = useRef(null); const rangepickerRef = useRef(null); + // debounce search input -> search + useEffect(() => { + const t = setTimeout(() => setSearch(searchInput), 300); + return () => clearTimeout(t); + }, [searchInput]); + useEffect(() => { - if (!rangeElRef.current) return; + if (!granted || !rangeElRef.current) return; let cancelled = false; @@ -139,18 +163,24 @@ export default function LogsPage() { rangepickerRef.current = null; }; - }, [status]); + }, [granted]); useEffect(() => { if (sessionError === REFRESH_TOKEN_ERROR) signIn(AUTH_PROVIDER_ID); }, [sessionError]); + + useEffect(() => { + if (!granted || !token) return; + 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); + const data = await fetchLogs(token, cursor ?? undefined, since, until, search, doorFilter, grantFilter); setLogs(data.logs); setNextCursor(data.cursor); setPageIndex(idx); @@ -167,32 +197,16 @@ export default function LogsPage() { setLoading(false); setRefreshing(false); } - }, [token, sinceDate, untilDate]); + }, [token, sinceDate, untilDate, search, doorFilter, grantFilter]); + // reset to page 0 and refetch whenever any filter changes useEffect(() => { + if (!granted) return; setCursorStack([null]); setPageIndex(0); setNextCursor(null); loadPage(0, null); - }, [loadPage]); - if (status === "loading") { - return ( - - - - ); - } - - //TBH, this should a global thing. - const allowed = session?.groups?.includes("rtp"); - if (!allowed) { - return ( - -

Access Denied

-

You must be an RTP to view this page.

-
- ); - } + }, [loadPage, granted]); const hasPrev = pageIndex > 0; const hasNext = nextCursor !== null; @@ -203,25 +217,6 @@ export default function LogsPage() { 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; - }); const PaginationControls = () => (
    @@ -234,15 +229,20 @@ export default function LogsPage() {
); + if (!granted) { + return ( + setGranted(true)} + /> + ); + } + return ( - -

Access Logs

-

Big Brother is watching

- - - - )}
- Events + Logs
@@ -323,12 +315,20 @@ export default function LogsPage() { Loading
- ) : filtered.length === 0 ? ( + ) : logs.length === 0 ? (

No log entries match your filters.

{(search || grantFilter !== "all" || doorFilter !== "all") && ( - )} @@ -342,20 +342,30 @@ export default function LogsPage() { Door Username Name + Keys Access - {filtered.map((entry) => ( - + {logs.map((entry) => ( + {formatTimestamp(entry.timestamp)} {entry.doorName ?? {entry.door}} - {entry.username ?? unknown} + {entry.username ?? unknown} + + {entry.name ?? -} + + {entry.uid === undefined ? ( + - + ) : entry.uid === null ? ( + "Mobile Key" + ) : ( + {entry.uid} + )} - {entry.name ?? } @@ -374,8 +384,7 @@ export default function LogsPage() { style={{ gridTemplateColumns: "1fr auto 1fr" }} > - Page {pageIndex + 1}  ·  {filtered.length} entr{filtered.length !== 1 ? "ies" : "y"} - {filtered.length !== logs.length && ` (filtered from ${logs.length})`} + Page {pageIndex + 1}  
@@ -386,3 +395,11 @@ export default function LogsPage() { ); } + +export default function LogsPage() { + return ( + + + + ); +} \ No newline at end of file diff --git a/components/AccessGate.tsx b/components/AccessGate.tsx new file mode 100644 index 0000000..a1e5754 --- /dev/null +++ b/components/AccessGate.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { Container, Button } from "react-bootstrap"; +import { apiFetch } from "@/lib/api"; + +export default function AccessGate({ + endpoint, + token, + onGranted, +}: { + endpoint: string; + token: string; + onGranted: () => void; +}) { + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [gateError, setGateError] = useState(null); + + async function requestAccess(e: React.FormEvent) { + e.preventDefault(); + if (!token) return; + setSubmitting(true); + setGateError(null); + try { + await apiFetch(endpoint, token, { + method: "POST", + body: JSON.stringify({ reason }), + }); + onGranted(); + } catch { + setGateError("Could not verify access. Try again."); + } finally { + setSubmitting(false); + } + } + + return ( + +
+
+
Access Reason Required
+

+ Viewing this page requires a stated reason. +

+
+
+ +