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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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+
Expand All @@ -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
```
Expand Down
33 changes: 30 additions & 3 deletions app/doors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -32,7 +40,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 Down Expand Up @@ -136,6 +144,25 @@ export default function DoorsPage() {
}
};

if (status === "loading" || !session) {
return (
<Container className="mt-5 text-center">
<Spinner animation="border" />
</Container>
);
}

const allowed = session?.groups?.includes("rtp");
if (!allowed) {
return (
<Container className="py-5 text-center text-muted">
<h4>Access Denied</h4>
<p>You must be an RTP to view this page.</p>
</Container>
);
}


if (loadingDoors) {
return (
<Container className="mt-5 text-center">
Expand Down
Loading