Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.next
out
build
pnpm-lock.yaml
5 changes: 5 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5"
}
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).

Expand All @@ -68,4 +74,3 @@ npm test
npm run build
npm start
```

138 changes: 95 additions & 43 deletions app/audit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ function formatTimestamp(iso: string): string {
});
}

async function fetchAuditEntries(token: string, cursor?: string, search?: string): Promise<AuditResponse> {
async function fetchAuditEntries(
token: string,
cursor?: string,
search?: string
): Promise<AuditResponse> {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);
if (search) params.set("search", search);
Expand All @@ -43,13 +47,13 @@ async function fetchAuditEntries(token: string, cursor?: string, search?: string

function AuditPageInner() {
const { data: session } = useSession();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [searchInput, setSearchInput] = useState("");
const [cursorStack, setCursorStack] = useState<Array<string | null>>([null]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [searchInput, setSearchInput] = useState("");
const [cursorStack, setCursorStack] = useState<Array<string | null>>([null]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const token = session?.accessToken ?? "";
const sessionError = session?.error;

Expand All @@ -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]);
Expand All @@ -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;
Expand All @@ -104,10 +116,28 @@ function AuditPageInner() {
const PaginationControls = () => (
<ul className="pagination pagination-sm justify-content-center mb-0">
<li className={`page-item ${!hasPrev ? "disabled" : ""}`}>
<a className="page-link" href="#" onClick={(e) => { e.preventDefault(); goPrev(); }}>Prev</a>
<a
className="page-link"
href="#"
onClick={(e) => {
e.preventDefault();
goPrev();
}}
>
Prev
</a>
</li>
<li className={`page-item ${!hasNext ? "disabled" : ""}`}>
<a className="page-link" href="#" onClick={(e) => { e.preventDefault(); goNext(); }}>Next</a>
<a
className="page-link"
href="#"
onClick={(e) => {
e.preventDefault();
goNext();
}}
>
Next
</a>
</li>
</ul>
);
Expand All @@ -117,7 +147,9 @@ function AuditPageInner() {
<div className="row mb-3 align-items-center">
<div className="col-12 col-md-4 mb-2 mb-md-0">
<div className="input-group">
<span className="input-group-text"><Icon path={mdiMagnify} size={0.75} /></span>
<span className="input-group-text">
<Icon path={mdiMagnify} size={0.75} />
</span>
<input
type="text"
className="form-control"
Expand All @@ -131,7 +163,10 @@ function AuditPageInner() {

<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<span><Icon path={mdiHistory} size={0.85} className="me-2" />Audit Logs</span>
<span>
<Icon path={mdiHistory} size={0.85} className="me-2" />
Audit Logs
</span>
</div>
<div className="card-body py-2 border-bottom">
<PaginationControls />
Expand All @@ -143,17 +178,32 @@ function AuditPageInner() {
</div>
) : entries.length === 0 ? (
<div className="card-body text-center py-5 text-muted">
<Icon path={mdiHistory} size={2} className="mb-3 opacity-25 d-block mx-auto" />
<Icon
path={mdiHistory}
size={2}
className="mb-3 opacity-25 d-block mx-auto"
/>
<p className="mb-0">No entries match your filters.</p>
{search && (
<button className="btn btn-link btn-sm mt-2" onClick={() => { setSearchInput(""); setSearch(""); }}>
<button
className="btn btn-link btn-sm mt-2"
onClick={() => {
setSearchInput("");
setSearch("");
}}
>
Clear filters
</button>
)}
</div>
) : (
<div className="table-responsive">
<Table hover size="sm" className="mb-0" style={{ fontSize: "0.875rem" }}>
<Table
hover
size="sm"
className="mb-0"
style={{ fontSize: "0.875rem" }}
>
<thead>
<tr>
<th style={{ width: "16%" }}>Timestamp</th>
Expand All @@ -166,10 +216,14 @@ function AuditPageInner() {
<tbody>
{entries.map((entry) => (
<tr key={entry._id}>
<td style={{ whiteSpace: "nowrap" }}>{formatTimestamp(entry.timestamp)}</td>
<td style={{ whiteSpace: "nowrap" }}>
{formatTimestamp(entry.timestamp)}
</td>
<td>{entry.username}</td>
<td>{entry.name}</td>
<td><span>{entry.action}</span></td>
<td>
<span>{entry.action}</span>
</td>
<td>{entry.reason}</td>
</tr>
))}
Expand All @@ -182,9 +236,7 @@ function AuditPageInner() {
className="card-footer d-grid align-items-center"
style={{ gridTemplateColumns: "1fr auto 1fr" }}
>
<small className="text-muted">
Page {pageIndex + 1} &nbsp;
</small>
<small className="text-muted">Page {pageIndex + 1} &nbsp;</small>
<div className="justify-self-center">
<PaginationControls />
</div>
Expand All @@ -201,4 +253,4 @@ export default function AuditPage() {
<AuditPageInner />
</AuthGate>
);
}
}
4 changes: 3 additions & 1 deletion app/auth-error/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Container className="mt-5" style={{ maxWidth: 480 }}>
Expand Down
22 changes: 18 additions & 4 deletions app/doors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "";

Expand All @@ -19,7 +20,7 @@ export default function DoorsPage() {
const [loadingDoors, setLoadingDoors] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [unlocking, setUnlocking] = useState<Record<string, boolean>>({});

// Stable ref so fetchStatuses doesn't change identity on token refresh
const tokenRef = useRef(token);
tokenRef.current = token;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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"}
/>
</Col>
))}
Expand All @@ -167,6 +172,7 @@ export default function DoorsPage() {
status={statuses[door.id]}
onUnlock={handleUnlock}
loading={false}
offline={statuses[door.id]?.guess === "offline"}
/>
</Col>
))}
Expand All @@ -175,4 +181,12 @@ export default function DoorsPage() {
)}
</Container>
);
}
}

export default function DoorsPage() {
return (
<AuthGate>
<DoorsPageInner />
</AuthGate>
);
}
Loading