English | 简体中文
Rota's scheduling, availability, shift coverage, leave and attendance workflows
run on an independent Temvia-generated Go API (api/) and React app (admin/).
The app uses Vite in development and Caddy in Compose.
Create an Employee role in Roles, select Employee self-service
(rota.self), and use that role for employee invitations. Employees can submit
their own qualified availability and use the roster, leave, shift-change and
leader-attendance workflows; ownership, qualification and publication windows
are still enforced. This role grants no administrative reads or writes.
rota.read grants management read views; rota.manage grants scheduling
management. Super Admin retains all capabilities. Temvia requires nonempty
roles, so do not create a permissionless employee role. Existing valid roles
retain their authenticated self-service access.
Migration of an existing Rota database is deferred. Use a new database for this
version; do not point these migrations at the old schema or overwrite an
existing .env. The fixed old implementation remains in Git history.
For a disposable current-source business browser check on Centaurus, install
admin dependencies and Chromium, then run bash scripts/test-business-browser.sh
under mise Node 24. It builds its own Compose project, tests role boundaries,
availability, localized email links, branding and account switching, and removes
only its own disposable containers and volume.
For the Compose route: Docker with Compose v2, Make, and Node.js 24 or later (for the generator and portable secret generation). Start the Docker engine. On macOS install Make using Xcode Command Line Tools if needed. The release workflow exercises this route once on an Ubuntu runner with a fresh Compose project, PostgreSQL volume, and Chromium browser. That is one CI path, not a general Linux/WSL2 or production compatibility claim. The complete local first-run route has been tested on macOS; native Windows PowerShell is outside this route.
Copy the environment inventory and fill all four secret values before starting the containers:
# New installation only; inspect and adapt an existing .env instead of overwriting it.
test -e .env || cp .env.example .env
chmod 600 .env
# edit .env and set POSTGRES_PASSWORD, PASSWORD_RESET_TOKEN_KEY, INVITATION_TOKEN_KEY, and EMAIL_SETTINGS_ENCRYPTION_KEY
# node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" # use the output for PASSWORD_RESET_TOKEN_KEY
# node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" # generate a separate value for INVITATION_TOKEN_KEY
# node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" # generate a separate value for EMAIL_SETTINGS_ENCRYPTION_KEY
make build
make migrate-up
make upRun the Node command separately for each token key and use a different output
for every secret. The same command also produces a suitable random value for the
PostgreSQL password; do not reuse a value across variables or commit .env.
The API prints a temporary setup link in its logs while initialization is incomplete. Open that link in the browser-visible admin origin. The token is kept in the URL fragment only until the admin removes it before rendering; it is sent only in the setup request body. Setup creates no session, so sign in after the administrator form succeeds.
docker compose logs apiOpen http://localhost:5173 after the first setup, or use the URL printed by
pnpm dev when developing the admin separately. The browser always calls the
relative /api path through Vite or Caddy, so APP_PUBLIC_URL must match the
origin in the address bar exactly.
PostgreSQL stores accounts, sessions, and rate-limit state. Valid sessions survive
an API or database restart; expired and revoked state is checked at request time
and reclaimed by a bounded background cleanup. make down does not remove the
PostgreSQL volume.
The API has basic abuse protection enabled by default without Redis. Login uses
separate source-IP, canonical-email, and global token buckets; password-reset
requests use their own source-IP, email, and global buckets. Password-reset
completion, invitation acceptance, and first setup have independent source-IP
buckets. Invitation create/resend shares actor and recipient buckets, while test
mail has separate global, actor, and recipient buckets. A source bucket is
charged for a reached anonymous attempt; object or global denial does not drain
an accepted-operation bucket. Successful login clears only its email bucket.
All buckets refill automatically and are retained for a finite period; repeated
rejections do not extend the token recovery schedule. A real quota denial is
HTTP 429 (rate_limited); an unavailable or timed-out limiter is HTTP 503
(dependency_unavailable) and fails closed without clearing an existing session
cookie. These limits protect one API instance and are not DDoS or SMTP-provider
quota protection.
The related *_RATE_LIMIT_* environment variables in .env.example expose
capacity and refill interval for every bucket. Values must be positive, intervals
must be at least 1ms, and the calculated retention must fit the database
representation; invalid values stop startup. Configure TRUSTED_PROXY_CIDRS
only with the immediate proxy networks. Direct peers are used when it is empty,
and malformed or untrusted forwarding headers cannot change the source identity.
Run the bundled migrations, including the abuse-protection forward migration,
before starting the API. For a public deployment, put a maintained edge proxy
or WAF in front of the API for connection, request-body, and broad traffic
controls; that is additional protection, not a prerequisite for these buckets.
The shipped small-admin defaults are:
| Operation | Buckets (capacity; one-token refill; sustained refill) |
|---|---|
| Login | IP 30; 6s; 10/min · email 5; 1m; 1/min · global 60; 6s; 10/min |
| Password-reset request | IP 30; 6s; 10/min · email 3; 20m; 3/hour · global 10; 6s; 10/min |
| Password-reset completion | IP 20; 1m; 1/min |
| Invitation acceptance | IP 30; 1m; 1/min |
| First setup | IP 10; 1m; 1/min |
| Invitation create/resend | actor 20; 1m; 1/min · recipient 3; 20m; 3/hour |
| Test email | global 20; 1h; 1/hour · actor 5; 1h; 1/hour · recipient 3; 20m; 3/hour |
Capacities are burst tokens, not a promise of a fixed request count. Refill is continuous in discrete interval steps and a bucket never exceeds capacity. Changing a capacity clips existing excess tokens on the next request; changing an interval applies the new recovery and finite-retention schedule then.
SHUTDOWN_TIMEOUT is the single graceful-shutdown budget. It defaults to 30s,
is read by the API, and is also used as Compose's stop_grace_period; the
budget starts at the first SIGINT/SIGTERM and includes a small internal exit
reserve. The API stops accepting new HTTP requests, drains in-flight requests
and already claimed mail in parallel, cancels maintenance, and closes the
database only after those workers finish. A timeout or a second termination
signal exits nonzero; invalid or non-positive values are rejected at startup.
Password recovery is handled by the API's PostgreSQL transactional outbox. The request endpoint only commits reset state and returns; the in-process mail dispatcher claims jobs with a short lease and sends them over SMTP afterwards. There is no message broker or separate worker. Delivery is at-least-once: a process interruption after SMTP accepts a message can produce a duplicate, and the stable Message-ID plus idempotent reset authority make that safe. Temporary failures retry with bounded exponential full-jitter backoff; expired jobs and old sent/canceled/dead rows are cleaned automatically.
make up enables the development-only Mailpit profile. Inspect reset and
security-notification messages at http://127.0.0.1:8025
(or the MAILPIT_UI_PORT you configure). Mailpit SMTP is private to the
Compose network on mailpit:1025; it is never published to the host. A normal
docker compose up without --profile development does not start Mailpit.
The API does not wait for SMTP readiness, so a provider outage leaves durable
outbox work to retry.
In production, configure SMTP security (starttls or tls), a real sender
address, and paired credentials when required by the provider in the System
settings page. System settings use one required, language-neutral System name
plus an optional Organization name; both are snapshotted into notification
emails. The System name appears in the interface, browser title, and system
emails regardless of interface or email language. Keep EMAIL_SETTINGS_ENCRYPTION_KEY
stable and back it up separately from PostgreSQL; it protects the stored SMTP
password. Keep
PASSWORD_RESET_TOKEN_KEY stable across restarts. Deliberate key rotation
invalidates pending reset-mail jobs (already delivered links remain consumable
because PostgreSQL stores their digest); affected users can request a fresh
link. Keep INVITATION_TOKEN_KEY stable as well; it is intentionally separate
from the reset key. Invitation links expire after 72 hours by default and may
be configured with INVITATION_LINK_TTL up to seven days. Run all current migrations before deploying this API. For rollback, back up
the database and inspect each affected migration before choosing the matching
API version; blindly applying one down migration is not a general rollback plan.
For an application upgrade, use this stop-and-back-up sequence. Keep the PostgreSQL service running while the API is stopped, and run each command only after the previous one succeeds:
(
set -eu
umask 077
# 1. Stop the API before changing its image or schema.
docker compose stop api
# 2. Use a private, persistent directory outside the project, not /tmp.
backup_dir="$(mktemp -d "${HOME:?}/temvia-backup.XXXXXX")"
chmod 700 "$backup_dir"
backup_file="$backup_dir/temvia-$(date -u +%Y%m%dT%H%M%SZ).sql"
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$backup_file.partial"
chmod 600 "$backup_file.partial"
mv "$backup_file.partial" "$backup_file"
printf 'Backup written to %s\n' "$backup_file"
# 3. Build fresh API, admin, and migration images from the updated source.
make build
# 4. Apply all forward migrations explicitly. Failure stops this subshell.
make migrate-up
# 5. Start the rebuilt services only after migration succeeds.
docker compose up -d api admin
)The subshell stops on any failed step without closing your interactive shell.
A .partial dump indicates a failed backup and must not be used for restore.
Keep successful backups on persistent storage and copy them to your protected
off-host backup storage; the example does not automate backup retention.
Back up .env, EMAIL_SETTINGS_ENCRYPTION_KEY, PASSWORD_RESET_TOKEN_KEY,
and INVITATION_TOKEN_KEY separately in a protected secret store before the
upgrade; never commit those values or place them in a public backup. A failed
build or migration must stop the procedure: inspect the error and restore the
database and matching images according to your rollback plan rather than
starting a schema-incompatible API. Do not use docker compose down -v; the
PostgreSQL volume contains the application data.
The user list includes active and disabled accounts and supports status filtering. User read access permits viewing users; user write access permits deactivating, reactivating, or deleting any other user, including Super Admins. Existing role-grant restrictions remain unchanged. You cannot deactivate or delete yourself, and lifecycle actions and role changes must leave at least one active Super Admin. The API enforces these rules transactionally and requires the displayed user version; after a conflict, reload the user before confirming again.
Deactivation immediately invalidates all existing sessions and blocks sign-in and password recovery while retaining the email, password, and assigned roles. Roles may still be edited and remain in use while assigned to disabled users. Reactivation requires a fresh sign-in; old sessions and reset links never become valid again. Previously signed-in users receive a deactivation message when their session is checked. Anonymous sign-in and recovery responses do not disclose account status.
Deletion requires individual confirmation and the target email. It is irreversible and does not require prior deactivation. Credentials, sessions, reset authority, and role assignments are removed. Associated records retain the original name, email, and user ID, with a Deleted user indicator. Operation history follows its existing retention policy; minimal historical identity records contain no credentials. Account deletion is not a promise to erase all personal information. The email may be invited again, but the new user gets a new identity and no previous credentials, roles, or historical attribution.
Invitations remain valid when their creator is disabled or deleted, subject to their existing expiry and authorization rules. New business modules should explicitly retain necessary associations rather than cascade-delete business records.
Apply all migrations before starting the new API. Downgrading the lifecycle migration refuses while disabled users or deleted identities exist, rather than restoring access or silently losing history. Plan rollback against actual data; do not clear data to bypass these protections.
Administrators with the operation-logs.read permission can open Operation
history from the authenticated navigation. The page supports time, actor UUID,
action, result, object type, and object ID filters, cursor pagination, and a
localized detail view. Successful and failed in-scope actions each produce one
best-effort result record; a storage outage can leave a gap. History is retained
for 180 days by default. Super Admins or users with settings.write can change
the value between 1 and 3650 days in System settings; cleanup runs hourly and
uses a bounded database operation.
Source IP records the direct peer address by default. In a reverse-proxy
deployment, set TRUSTED_PROXY_CIDRS to the proxy's immediate peer network so
the API can safely use its X-Forwarded-For or X-Real-IP value. Headers from
untrusted peers are ignored. Never include a public client network in this
setting.
- Go 1.27 or later for
api/. - Node.js 24 or later and pnpm 11.24.0 for
admin/.
cd api
go run ./cmd/serverGET http://127.0.0.1:8080/health returns {"status":"ok"}. Set HTTP_ADDR
to override the listener; it defaults to local access only.
When running the API outside Compose, export the values in .env into the
process environment and use hosts reachable from the host machine. The Go API
reads process environment variables only; it does not parse .env files.
go test ./...
go vet ./...
go build -o bin/server ./cmd/server
# Benchmark the fixed Argon2id profile on the deployment target before release.
go test -bench='Benchmark(Hasher|Verifier)$' -benchtime=1x ./internal/auth/adapter/passwordThe recovery API accepts POST /api/auth/password-reset/request with an email
and returns the same 202 {"status":"accepted"}
for known and unknown accounts. The reset email opens
/reset-password#token=...; the browser removes the fragment before React
mounts. Completion accepts the token and a policy-compliant password,
returns 204, clears any presented session cookie, invalidates all old
sessions, and requires an explicit login with the new password.
After setup, the first administrator is assigned to the immutable Super Admin role. The Users, Invitations, and Roles pages are grouped under the
authenticated Users & Access navigation. The live permission catalog uses
independent resource.read and resource.write grants for users, roles,
invitations, and settings. Feature combinations (for example, invitation
creation plus roles.read) are shown in the role editor and validated by the
API. Super Admins retain full access, while delegated managers may create,
resend, renew, or revoke only invitations whose roles fit their own effective
permissions.
Custom roles must retain at least one permission, users and invitations at
least one role, and a role cannot be deleted while assigned to a user or
invitation. The API refuses any change that would leave zero usable Super
Admin accounts.
Invitations are separate from activated users. A one-time link opens
/accept-invitation#token=...; the admin removes the fragment before render,
acceptance creates the account and assignments atomically, and the recipient
must explicitly log in afterwards.
In another terminal, from the project directory:
cd admin
pnpm install --ignore-scripts
pnpm devOpen the URL printed by Vite (by default http://127.0.0.1:5173). If that port is
in use, Vite automatically tries the next available port. The first install
creates admin/pnpm-lock.yaml; keep it in version control.
The API's APP_PUBLIC_URL must equal that exact printed origin, including the
selected port. If Vite falls back to another port, update APP_PUBLIC_URL in
the root .env, recreate the API with docker compose up -d api, and use the
new setup link from its log. Restarting alone does not reload environment values.
The Vite proxy reads API_PORT from the same root .env.
pnpm lint
pnpm check
pnpm build
pnpm preview --host 127.0.0.1pnpm lint runs Oxlint; pnpm check runs TypeScript; pnpm test runs unit and
component tests. The preview command only inspects a local build. Production
serving is provided by the Caddy image in make up, with /api proxied to the
private API and navigation paths falling back to index.html.
Each application owns its dependencies and configuration. There is no root
workspace or runtime dependency on Temvia, and no dependencies are installed by
the generator. The generic Compose file does not assume public DNS or TLS;
configure direct Caddy TLS or an external ingress and keep APP_PUBLIC_URL
equal to the public HTTPS origin. Nginx can replace Caddy if it preserves the
same exact API proxy and SPA fallback behavior.
The System monitoring → Online users page lists users with at least one valid sign-in session, grouped by account. A session remains online while it is valid even when its browser page is closed or it has had no recent activity. The page shows the user's name, email, valid session count, and last activity. The list refreshes every 15 seconds; open authenticated pages check their session every 30 seconds. Browsers may throttle these checks when suspended.
Grant online-users.read to view the list and online-users.write to force sign
users out. The write permission can force sign out any user, including a Super
Admin or the current user; the read permission remains independent. Force sign
out revokes all
existing sessions using the PostgreSQL account version, including inactive
devices; users may sign in again. Revoked sessions are denied on their next
request, and open pages show a localized expiry message before returning to
login after a session check. Successful and failed force sign-out actions appear
in operation history.
GET /api/online-users returns { users: [{ id, name, email, lastSeenAt, sessionCount }] }. POST /api/online-users/{id}/kick requires a same-origin
request and returns 204. Session credentials are never exposed in these APIs.
Use APP_ENV=production and set APP_PUBLIC_URL to the public HTTPS origin.
Provide TLS through an ingress or a customized Caddy configuration; the supplied
Compose gateway only listens on loopback HTTP. Keep API and database ports
private. Run make build, make migrate-up, then
docker compose up -d api admin without the development profile. Do not use
make up for production, because it enables Mailpit. Configure real SMTP through
System settings. For local Mailpit use host mailpit, port 1025, security
none, no credentials, and a test sender in System settings.
If a host port is occupied, change its mapping in .env; when changing
ADMIN_PORT, change APP_PUBLIC_URL to the matching browser origin too.
For admin development, stop the Compose admin (docker compose stop admin)
and follow the Admin section. Recreate the API after changing .env using
docker compose up -d api; restarting alone does not reload environment values.
The API must retain the same public origin used by your browser.
Back up PostgreSQL and the encryption/signing keys separately. Avoid docker compose down -v unless you intend to remove the project's database volume.
The supplied Temvia code is MIT licensed; retain LICENSE and third-party
notices in admin/UPSTREAM.md. You own your added code. This project does not
automatically receive template upgrades. Consult
Temvia releases and apply
relevant fixes yourself. Public feedback goes to
Issues; private vulnerability
reporting is described in
SECURITY.md.
Maintenance is best effort with no fixed response time or old-version support.