Skip to content
Team6083Public

About

A simple time tracking system for CMS Robotics teams

Resources

Stars

3 stars

Watchers

3 watching

Forks

Repository files navigation

OverHours

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.

Tech stack

  • Next.js 16 (App Router, standalone output) 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

Local development

Requirements: a Node.js version matching the engines field in package.json, and a MongoDB instance.

  1. Install dependencies: npm install

  2. Create a .env file (it is git-ignored) with at least the required variables listed below.

  3. Generate the Prisma client and start the dev server:

    npx prisma generate
    npm run dev
  4. 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.

Environment variables

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/* with every permission, except /api/admin/jobs/*. Without it, only Keycloak tokens are accepted; see Admin API.
CRON_SECRET For auto-lock and team reminders 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.
OIDC_AUDIENCE, API_ROLES_* No Which Keycloak tokens the Admin API accepts and what they may call; see Admin API.
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.
REMINDER_LOCALE No Language of the team reminders sent to Slack: en (default) or zh-TW. See Team reminders.
REMINDER_MENTION No on tags members in team reminders by email (needs overlooking-bot to resolve emails); off (default) lists names and sends no email. See Team reminders.
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.

Build and run

npm run build   # next build (standalone output in .next/standalone)
npm run lint    # eslint

The 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.

Testing

npm test   # vitest run

Unit 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.

Auth configuration

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.

Security headers

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.com is allowed in script-src and https://cloudflareinsights.com in connect-src (constants at the top of src/lib/csp.ts). Any other inline script Cloudflare injects (for example Rocket Loader) cannot carry our nonce and is blocked.
  • style-src allows 'unsafe-inline': Chakra UI/emotion inject <style> tags and inline style attributes that cannot be nonced cleanly.
  • img-src allows https: because user avatars come from the Keycloak picture claim. 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 as Content-Security-Policy-Report-Only, which does not block anything. The value must be exactly the string true (TRUE or 1 enforce the policy). docker-compose.yaml sets it because it serves the production build over plain http://localhost.

Health checks

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.

Auto-lock

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).

Attendance

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.

To save typing, the week form's quick fill menu (next to All days) can copy last week's answers or apply the member's usual week, and save the current week as their usual week (User.attendancePattern, one answer per weekday). Both only fill days that can still be changed and only change the form; the member still saves.

Run npx prisma db push once after deploying so the unique index exists and the WillCome value is accepted.

Answer reliability compares answers with time logs: of the days a member answered, the share where what they did matched what they said. Will come matches a time log that day, can't come matches none, and if needed always matches. Days not answered aren't counted, and only days that are over count; any time log that day counts as coming, locked ones included. Members see their own last 30 days on /attendance; admins see everyone under Report → Answer reliability. 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.

Teams

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.

Team reminders

Each team can remind its members in Slack to fill in their availability. Admins set it on the team's page under /admin/teams:

Setting Meaning
On / off Whether the team's reminder goes out
Days that must be filled in Weekdays that count, e.g. only Wednesday and Saturday
Look ahead From tomorrow, how many days ahead those weekdays must already be answered (1–28)
Send at Time of day in TZ

Every day after its time, the reminder tags each member of the team, sub-teams included, who hasn't answered one of those days, with the days missing. A team with nobody missing sends nothing. A big team's reminder covers everyone below it and goes to its own channel; a sub-team sets its own reminder for its own channel, and nothing is inherited. The settings show who would be reminded right now.

The channel is the overlooking-bot webhook set on the same page; it is never shown again after saving. The request is a Slack Block Kit message (POST {"text", "blocks"}): the team as a header, a one-line summary ("12 members haven't filled in their availability for 11/15–11/21"), a bulleted list, and a link to the attendance page. Each bullet is the days in bold ("11/15–11/21 (7 days)") followed by every member missing those days, so the list grows with the different cases, not the members; the groups missing the most days come first. Everyone missing is listed; a list too large for one message continues in further messages, and if any of them fails the run is retried as a whole. text is the short fallback shown in notifications. Text is in REMINDER_LOCALE.

REMINDER_MENTION decides whether members are tagged. off (the default, and any value other than on) lists their names and sends no email. on puts each member in as a Slack user element whose user_id is their email, which overlooking-bot has to resolve to the Slack user; turn it on only once the bot does.

The reminders go out when a scheduler calls this endpoint with CRON_SECRET, like auto-lock. Call it every few minutes; each reminder is sent at most once a day, and a failed send is retried on the next call:

curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://overhours.example.com/api/admin/jobs/attendance-reminders
# {"data":{"runs":[{"ruleId":"...","teamId":"...","result":"sent"}]}}

result is sent, nobodyMissing, noWebhook (on but no channel set; nothing is sent, and adding a webhook later that day still sends) or failed (logged; retried on the next call, or, for a rule with an unknown kind or channel, until it is fixed). Run npx prisma db push once after deploying.

Each reminder is a ReminderRule: a kind with its config (only unanswered: the weekdays and look-ahead above) and a channel with its target (only slack-webhook: the URL), both JSON checked per kind and channel. The page edits one rule per team, but a team can hold several, and a new condition or channel needs no schema change. Each run is claimed by inserting a ReminderDelivery row, unique per rule and day, which is deleted again if sending fails.

Admin API

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/users

ADMIN_API_KEY can call every endpoint. A program that only needs part of the API, such as a spreadsheet that reads attendance, should instead use a token issued by Keycloak, for example from a service account client with the client credentials grant:

TOKEN=$(curl -s -d grant_type=client_credentials -d client_id=$CLIENT -d client_secret=$SECRET   "$AUTH_KEYCLOAK_ISSUER/protocol/openid-connect/token" | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" "http://localhost:3000/api/admin/attendance?start=2027-01-11&end=2027-01-17"

The token must be an access token (typ Bearer, with a sub), verified against AUTH_KEYCLOAK_ISSUER (signature via its JWKS, iss, exp), and its aud must contain OIDC_AUDIENCE (default AUTH_KEYCLOAK_ID; the server doesn't start if neither is set). We suggest a separate client for callers, such as overhours-api with only the service account enabled and no login flow, that carries their roles and an Audience mapper, apart from the client users sign in with. What it may call comes from its Keycloak roles: each permission below has an API_ROLES_* variable in the same syntax as ADMIN_ROLES, and a token with any of those roles gets the permission. A token with an admin role (ADMIN_ROLES) gets every permission; a role-less token gets none. Tokens work even when ADMIN_API_KEY is unset.

Permission Roles from Endpoints
attendance:read API_ROLES_ATTENDANCE_READ GET /api/admin/attendance
timelogs:read API_ROLES_TIMELOGS_READ GET /api/admin/timelogs, GET /api/admin/timelogs/{id}, GET /api/admin/timelogs/locked
timelogs:write API_ROLES_TIMELOGS_WRITE POST /api/admin/timelogs, PUT / DELETE /api/admin/timelogs/{id}
users:read API_ROLES_USERS_READ GET /api/admin/users, GET /api/admin/users/{id}
users:write API_ROLES_USERS_WRITE POST /api/admin/users, PUT / DELETE /api/admin/users/{id}

A valid token without the permission gets 403 forbidden; an invalid or expired token, or one that isn't an access token (an ID or refresh token), gets 401. If Keycloak can't be reached to check it the answer is 503 upstream_unavailable, or 504 upstream_timeout if it doesn't answer in time. POST /api/admin/jobs/auto-lock takes CRON_SECRET only (see Auto-lock).

Endpoints:

  • GET /api/admin/users / POST /api/admin/users
  • GET /api/admin/users/{id} / PUT /api/admin/users/{id} / DELETE /api/admin/users/{id}
  • GET /api/admin/timelogs / POST /api/admin/timelogs
  • GET /api/admin/timelogs/locked — locked logs with user details, filtered by clock-in time; ?format=csv for CSV
  • POST /api/admin/jobs/auto-lock — lock every session past the daily cut-off; for an external scheduler; uses CRON_SECRET, not ADMIN_API_KEY (see Auto-lock)
  • POST /api/admin/jobs/attendance-reminders — send each team's daily Slack reminder once its time has passed; uses CRON_SECRET (see Team reminders)
  • 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.

Deployment

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).

About

A simple time tracking system for CMS Robotics teams

Resources

Stars

3 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages