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
21 changes: 21 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Docker

on:
push:
branches:
- main
pull_request:
branches:
- main

env:
IMAGE_NAME: gatekeeper-frontend

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Build image
run: docker build . --file Dockerfile
21 changes: 21 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Lint

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install and lint
run: |
npm ci
npm run lint
npm run format:check
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
Loading