OverHours is a web application for tracking lab hours. Members sign in through Keycloak (SSO) and log the time they spend in the lab; admins manage users, teams and statistics ranges and generate reports. An API-key authenticated Admin API allows external systems to manage users and time logs.
- Next.js 16 (App Router,
standaloneoutput) with React and TypeScript - Chakra UI v3 for the interface and next-intl for i18n (
messages/) - Auth.js (
next-auth) with the Keycloak provider - Prisma with MongoDB
- Sentry for error reporting
Requirements: a Node.js version matching the engines field in package.json, and a MongoDB instance.
-
Install dependencies:
npm install -
Create a
.envfile (it is git-ignored) with at least the required variables listed below. -
Generate the Prisma client and start the dev server:
npx prisma generate npm run dev
-
Open http://localhost:3000.
docker-compose.yaml only builds and runs the application image (app, port 3000, NODE_ENV=production).
It does not include a database yet, so MongoDB must be provided separately and DATABASE_URL pointed at
it. The Dockerfile reads the Sentry auth token from a sentry-auth-token build secret (for example
docker build --secret id=sentry-auth-token,env=SENTRY_AUTH_TOKEN .). docker-compose.yaml declares that
secret but does not wire it into the app build, so builds through compose do not upload source maps.
Variables are read at runtime unless noted otherwise.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | MongoDB connection string used by Prisma. |
AUTH_KEYCLOAK_ISSUER |
Yes | Keycloak realm issuer URL. |
AUTH_KEYCLOAK_ID |
Yes | Keycloak client ID. |
AUTH_KEYCLOAK_SECRET |
Yes | Keycloak client secret. |
AUTH_SECRET |
Yes (production) | Secret Auth.js uses to sign and encrypt session cookies. |
AUTH_URL |
No | Canonical public URL of the app. Also used by sign-out to build the origin; may be needed behind a reverse proxy. |
AUTH_TRUST_HOST |
No | Set to true to let Auth.js trust the Host header. Auth.js variable, not read by this repository's code; often needed behind a reverse proxy. |
ADMIN_ROLES, ALLOWED_ROLES, AUTH_KEYCLOAK_ALGORITHMS, AUTH_KEYCLOAK_VERIFY_GRACE_SEC, AUTH_KEYCLOAK_ROLES_CLAIM |
No | Role and token verification settings; see Auth configuration. |
NEXT_MANUAL_SIG_HANDLE |
No | Disables the shutdown flag used by /api/readyz; see Health checks. |
ADMIN_API_KEY |
For the Admin API | Bearer token accepted by /api/admin/*, except /api/admin/jobs/*. Admin API requests are rejected when it is unset. |
CRON_SECRET |
For auto-lock | Bearer token accepted by /api/admin/jobs/* and nothing else, for the scheduler (e.g. a Kubernetes CronJob). The jobs endpoint is rejected when it is unset (a startup warning is logged if AUTO_LOCK_TIME is set); ADMIN_API_KEY is not accepted there. Must differ from ADMIN_API_KEY, or the server refuses to start. |
ORG_NAME |
No | Name of the organisation running this instance. Shown in the header, footer and page description. When unset, no organisation text is shown. |
OVERHOURS_TIMELOG_MIN_DURATION_SEC |
No | Minimum duration in seconds for a clocked-in session; clocking out sooner deletes the log. Default: 0. |
AUTO_LOCK_TIME |
No | Daily cut-off as HH:mm in TZ. Sessions still clocked in at the cut-off are locked as abnormal clock-outs; see Auto-lock. Unset: no automatic locking. A malformed value stops the server from starting. |
TZ |
No | IANA time zone (e.g. Asia/Taipei) of the server, used for AUTO_LOCK_TIME and for times in locked-log reports and CSV exports. Default: the container's zone, usually UTC, so set it whenever auto-lock is enabled. An invalid zone stops the server from starting. |
SENTRY_DSN |
No | Sentry DSN for the server and edge runtimes. |
NEXT_PUBLIC_SENTRY_DSN |
No | Sentry DSN for the browser. Inlined at build time. |
SENTRY_AUTH_TOKEN |
No | Sentry auth token used to upload source maps. Build time only; secret. |
NEXT_PUBLIC_VERSION |
No | Version shown in the footer badge. Inlined at build time. |
NEXT_PUBLIC_COMMIT_SHA |
No | Commit shown in the footer badge when no version is set. Inlined at build time. |
NEXT_PUBLIC_BUILD_DATE |
No | Build date shown next to the commit. Inlined at build time. |
CSP_REPORT_ONLY |
No | Set to true to send the CSP as Content-Security-Policy-Report-Only (violations are logged in the browser console, nothing is blocked). See Security headers. |
AUTH_SECRET and AUTH_TRUST_HOST are Auth.js settings; see the
Auth.js deployment guide for details.
npm run build # next build (standalone output in .next/standalone)
npm run lint # eslintThe build uses output: "standalone", so the production server is started with node server.js from
the standalone output (the Dockerfile copies public and .next/static into it first). For local work
use npm run dev; running the container image is the supported way to run a production build.
npm test # vitest runUnit tests use Vitest and live next to the code as src/**/*.test.ts (configured in
vitest.config.mts). They cover pure logic only (no database or Keycloak needed), for example the role
parsing in src/lib/auth-roles.ts and the token error classification in src/lib/auth-errors.ts.
CI runs npm test in the check job.
Sign-in uses Keycloak via Auth.js (AUTH_KEYCLOAK_ID, AUTH_KEYCLOAK_SECRET, AUTH_KEYCLOAK_ISSUER).
Roles are read from the Keycloak access token, which is verified (signature via the issuer's JWKS, iss,
exp, and azp equal to AUTH_KEYCLOAK_ID) before any role is trusted.
Users are matched to the accounts they sign in with (an Account per Auth.js provider id and OIDC sub), not by
email, so changing the email in Keycloak keeps the same user and updates their email here. A user without an
account yet (created before accounts existed, or by an admin) is linked on their first sign-in with the same email.
If that email belongs to a user already linked to another account, the sign-in is refused with an "Account
conflict" page; an admin resolves it by unlinking the old account on the user's page, or by merging. When a user's
new email is already taken by another user, their old email is kept and the users list marks them "Out of sync"
until an admin merges them.
Merging moves every linked account to the kept user. The email of a user with exactly one account follows that account and cannot be edited in the app or the Admin API; a user with several accounts keeps their email until an admin edits it, so it does not switch with every sign-in.
Accounts are keyed by the provider id (keycloak), not the issuer URL, because Keycloak keeps sub when its
hostname or realm name changes: changing AUTH_KEYCLOAK_ISSUER for such a move needs no data change (the issuer
stored on each account is for reference and updates at the next sign-in). Moving to a different Keycloak instance
or realm whose users have new IDs changes every sub, so no sign-in would match and every user would hit "Account
conflict". In that case remove the old links before switching, so each user is linked again by email at their next
sign-in (a user whose email differs between the two systems gets a new user, to merge afterwards):
// mongosh, against DATABASE_URL
db.Account.deleteMany({ provider: "keycloak" })Run npx prisma db push once after deploying so the Account indexes exist.
Role settings are comma-separated lists. Realm roles are written as-is (admin) and client roles as
client:role (overhours:manager); a user's roles are the union of realm_access.roles and every
client's resource_access.<client>.roles.
| Variable | Default | Meaning |
|---|---|---|
ADMIN_ROLES |
admin |
Users with any of these roles are admins. Unset or blank falls back to the realm role admin. |
ALLOWED_ROLES |
unset | If set, users with none of these roles (and who are not admins) are refused with a "No access" page. If unset, every authenticated user is allowed. Admins are always allowed. |
AUTH_KEYCLOAK_ALGORITHMS |
RS256 |
Accepted access token signature algorithms (comma-separated, asymmetric only: RS*/PS*/ES*/EdDSA; anything else fails at startup). |
AUTH_KEYCLOAK_VERIFY_GRACE_SEC |
900 |
How long a session keeps its previous role while token verification keeps failing transiently. |
If Keycloak cannot be reached to verify a token (JWKS fetch failure), the user keeps their previous role and
verification is retried (at most every 30s per server process); only an invalid token (bad signature, iss, exp, azp) drops roles at once. If verification keeps failing for longer than AUTH_KEYCLOAK_VERIFY_GRACE_SEC, it is treated as invalid too (roles dropped, ALLOWED_ROLES enforced).
Not verified against a real Keycloak: the sign-out cookie lookup picks the secure or plain Auth.js cookie name from
AUTH_URL or x-forwarded-proto; if neither is set correctly behind a proxy, sign-out still clears the local session
but falls back to client_id instead of id_token_hint.
Signing out also ends the Keycloak SSO session (redirect to the realm's end_session endpoint with
id_token_hint and post_logout_redirect_uri). The app origin (AUTH_URL, or the request origin) must be
listed in the Keycloak client's "Valid post logout redirect URIs".
The "No access" / sign-in error page also has a "Sign out" button (a POST server action, so a link cannot log
anyone out) that does the same without needing a session: it clears any Auth.js session cookie and ends the
Keycloak session with client_id instead of id_token_hint, so Keycloak shows its logout confirmation page. This
lets a refused user switch accounts instead of being silently signed in again. It lands on the app origin, which
is not auto-redirected to sign-in for anonymous users, so no additional redirect URI needs registering.
Every response carries (set in next.config.ts, so API routes, static assets, error pages and the health endpoints included):
| Header | Value |
|---|---|
X-App-Version |
NEXT_PUBLIC_VERSION (the git tag, e.g. v2.1.0) |
X-App-Commit |
NEXT_PUBLIC_COMMIT_SHA |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
X-Frame-Options |
DENY |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
X-App-* values are fixed at build time from the Docker build args and intentionally disclose the build (version and commit) on every response, as the team standard requires; a header whose value was not set (local development) is omitted. HSTS is not set by the app; set it at the edge.
HTML responses also get a per-request Content-Security-Policy with a fresh nonce, built in src/lib/csp.ts and set by src/proxy.ts (pages are rendered dynamically, which nonces require). Scripts are limited to 'self', the nonce and the Cloudflare beacon host; object-src 'none', base-uri 'self', frame-ancestors 'none', form-action allows 'self' and the Keycloak origin from AUTH_KEYCLOAK_ISSUER. 'unsafe-eval' is added outside production only and upgrade-insecure-requests in production only (so a production build served over plain http:// needs CSP_REPORT_ONLY=true). Notes:
'strict-dynamic'is deliberately not used: it makes browsers ignore host sources, which would block the script Cloudflare injects (it has no nonce).- Cloudflare Web Analytics:
https://static.cloudflareinsights.comis allowed inscript-srcandhttps://cloudflareinsights.cominconnect-src(constants at the top ofsrc/lib/csp.ts). Any other inline script Cloudflare injects (for example Rocket Loader) cannot carry our nonce and is blocked. style-srcallows'unsafe-inline': Chakra UI/emotion inject<style>tags and inline style attributes that cannot be nonced cleanly.img-srcallowshttps:because user avatars come from the Keycloakpictureclaim. Sentry uses the same-origin tunnel route, so it needs no extra host.- To roll out or debug, set
CSP_REPORT_ONLY=true: the same policy is sent asContent-Security-Policy-Report-Only, which does not block anything. The value must be exactly the stringtrue(TRUEor1enforce the policy).docker-compose.yamlsets it because it serves the production build over plainhttp://localhost.
| Endpoint | Purpose | Behaviour |
|---|---|---|
GET /api/healthz |
Liveness | Always 200 {"status":"ok"} while the process runs. No dependency checks. |
GET /api/readyz |
Readiness | 200 {"status":"ok"} when MongoDB answers a ping within 800 ms; otherwise 503 ({"status":"unavailable"}, or {"status":"shutting_down"} after SIGTERM). |
Both endpoints are unauthenticated, return JSON without internal details and are sent with Cache-Control: no-store. The MongoDB check is shared between concurrent requests and its result is cached for 2 seconds per process. The 800 ms ping timeout is deliberately below the Kubernetes default probe timeoutSeconds of 1.
On Kubernetes, use /api/healthz for the liveness probe and /api/readyz for the readiness probe.
On SIGTERM, Next.js's standalone server stops accepting new connections, lets in-flight requests finish and exits (code 143). Readiness probes therefore mostly fail by connection refusal, and /api/readyz only reports shutting_down on connections that are still open during the drain. Add a preStop sleep of a few seconds so the pod is removed from the Service endpoints before it stops accepting connections, and set terminationGracePeriodSeconds longer than that sleep plus your longest request. If NEXT_MANUAL_SIG_HANDLE is set, the shutdown flag is not installed and /api/readyz never reports shutting_down.
A session that is not clocked out normally is locked: it gets status LOCKED, does not count toward
hours, and appears in the Locked Logs report. Admins can lock a session by hand (Lock on the home page,
or setting the status in the log editor / Admin API). With AUTO_LOCK_TIME set, a session may
not span the daily cut-off: with 23:00, a session started at 22:30 must end by 23:00 that day, and one
started at 23:30 by 23:00 the next day. A session that passed its cut-off is locked at the cut-off (its out
time is the cut-off).
The app checks this only at clock-out: clocking out (or an admin clocking someone out) after the cut-off
locks the session instead and tells the user. A session whose user never clocks out stays "currently in"
until something locks it, so run a scheduler (for example a Kubernetes CronJob) that calls the jobs endpoint
regularly, e.g. every few minutes, with CRON_SECRET (not ADMIN_API_KEY):
curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://overhours.example.com/api/admin/jobs/auto-lock
# {"data":{"enabled":true,"lockedCount":1,"lockedIds":["..."]}}It locks every session past its cut-off and is safe to call as often as wanted. Each lock records a reason
(AUTO_TIMEOUT or ADMIN), when it happened and, for locks made in the web UI, which admin made it. Logs
locked before this was recorded show the reason as unknown.
Locked logs can be exported for violation records as CSV from Report → Locked Logs (admins; dates are
whole days in TZ), or through GET /api/admin/timelogs/locked (JSON or format=csv).
Members answer each day on /attendance with one of three answers, and can clear a day back to not filled in:
| Answer | Meaning | Stored as |
|---|---|---|
| Will come | Promised to be there | WillCome |
| If needed | Can be there if asked | Available |
| Can't come | Away | Unavailable |
| Not filled | No answer; not the same as will come | no row |
Past days cannot be changed. Every member can see who gave each answer on each day; people who have not filled
it in are not listed. Rows saved before "will come" existed keep Available and show as if needed.
Run npx prisma db push once after deploying so the unique index exists and the WillCome value is accepted. Set TZ, because "today" is worked
out in that zone.
External programs, such as a Slack bot, read everyone's status through the Admin API:
curl -H "Authorization: Bearer $ADMIN_API_KEY" "https://overhours.example.com/api/admin/attendance?start=2027-01-11&end=2027-01-17"
# {"data":{"start":"2027-01-11","end":"2027-01-17","users":[{"id":"...","name":"...","email":"...","days":[{"date":"2027-01-11","status":"AVAILABLE"}]}]}}status is AVAILABLE, UNAVAILABLE or NOT_FILLED; "will come" is reported as AVAILABLE for now, so
existing clients see no new value. A request covers at most 92 days.
Admins manage teams on /admin/teams. Teams form a tree: a team can have sub-teams, and a team that still has
sub-teams cannot be deleted. Members of a sub-team also count as members of every team above it: a team's member
list and member count include its sub-teams' members, who are managed (and removed) on their own sub-team. A
member's teams are shown on the home page; only the teams they are directly in are listed there.
A team can list auto-join roles, in the same syntax as ADMIN_ROLES (role for a realm role, client:role
for a client role). A user with any of them joins the team, and leaves it when they no longer have any. Roles are
read from the verified access token at sign-in and on every token refresh, so a role change in Keycloak reaches an
active user within one access token lifetime and other users at their next sign-in. Changing a team's roles works
the same way: nobody is added or removed until they next sign in or refresh.
Members added by hand are never removed by roles, even when they have none of the team's roles. A member who joined by role is marked "Role". Admins can still remove them by hand, for example someone who left long ago and never signs in again (role memberships are only updated at sign-in, so removing their role in Keycloak alone does not take them off the team). If they still have the role, they rejoin at their next sign-in or token refresh; to keep them out, remove the role in Keycloak or from the team as well. When users are merged, the admin chooses which of their teams the kept user stays in; each kept team keeps how it was joined (by hand or by role).
The roles must be in the access token, which they are by default (realm_access and resource_access). Run
npx prisma db push once after deploying.
An API-key authenticated REST API is available for managing users and time logs from
external systems. Set ADMIN_API_KEY in the environment, then send it as a Bearer token:
curl -H "Authorization: Bearer $ADMIN_API_KEY" http://localhost:3000/api/admin/usersEndpoints:
GET /api/admin/users/POST /api/admin/usersGET /api/admin/users/{id}/PUT /api/admin/users/{id}/DELETE /api/admin/users/{id}GET /api/admin/timelogs/POST /api/admin/timelogsGET /api/admin/timelogs/locked— locked logs with user details, filtered by clock-in time;?format=csvfor CSVPOST /api/admin/jobs/auto-lock— lock every session past the daily cut-off; for an external scheduler; usesCRON_SECRET, notADMIN_API_KEY(see Auto-lock)GET /api/admin/timelogs/{id}/PUT /api/admin/timelogs/{id}/DELETE /api/admin/timelogs/{id}
The full OpenAPI 3.0 schema is available at /openapi/admin-api.json.
The application is published as a container image: ghcr.io/team6083/overhours (based on node:26-alpine).
Image tags:
| Trigger | Tags |
|---|---|
Push to main |
main, sha-<7 chars> |
Release tag vX.Y.Z |
X.Y.Z (no leading v), X.Y, latest |
Pin deployments to X.Y.Z (or the commit sha- tag); latest and X.Y are moving tags.
Existing vX.Y.Z tags remain available.
Pre-release tags (vX.Y.Z-rc.1) get only the full version tag (X.Y.Z-rc.1): no X.Y and no latest.
latest and X.Y point at whichever release was published last, so releases must be tagged in version
order; tagging a backport (e.g. v2.0.12 after v3.0.0) or re-running an old tag moves them backwards,
and the maintainer must then re-tag latest/X.Y manually.
Provide the environment variables above at runtime; the container listens on port 3000 and runs as the
non-root user 65532:65532. App files are root-owned and read-only for that user; the only directory
the Dockerfile makes writable is .next/cache (Next.js runtime cache).