Qwik City member portal — public signup, officer approval, MS365/Entra SSO, Active Directory provisioning, admin configuration, and resources hub.
Members can change their ACM Active Directory password from Your profile. The form requires the current password and reports AD's password policy rejections. Password changes support HTTP and HTTPS Windows API connections, including in production. Use HTTP only on a trusted private network. See Windows API configuration for permissions and rollout checks.
src/— Qwik City SSR app (Fastify runtime)src/worker/— provision outbox worker + nightly alumni digest (Node 24+)windows-api/— ASP.NET minimal API (New-ADUserwrapper) — runs on Windowsdrizzle/0000_initial.sql— Postgres schema + seeds (applied byscripts/migrate.ts)terraform/— Azure Entra app registration + k8s Secrethelm/acm-member-portal/— Helm chart (in-cluster CloudNativePG or an external database)k8s/— raw manifests kept as a reference, plussecret.example.yaml
- Node 24+ runtime (
node --version) - Docker (for the container build)
- Helm 3.14+
- Kubernetes cluster with Traefik (the chart does not create an Ingress)
- CloudNativePG operator, unless you point the chart at an external Postgres
(
database.enabled=false) - An ACM Microsoft 365 tenant (admin role)
- An on-prem Active Directory with an
acm-uic.orgUPN suffix - A self-hosted SMTP relay (or use the M365 SMTP alternative — see
windows-api/README.md) - An S3-compatible bucket for Terraform state + a DynamoDB-compatible table for locks
# 1. Env
cp .env.example .env
# Fill MICROSOFT_TENANT_ID, MICROSOFT_CLIENT_ID, etc. (the latter will come from
# `terraform output client_id` after step 3).
# 2. Build the image
docker build -t ghcr.io/acm-uic/acm-member-portal:latest .
docker push ghcr.io/acm-uic/acm-member-portal:latest
# 3. Entra app registration + dev Secret
cd terraform
terraform init
terraform apply -var-file=dev.tfvars
cd ..
# 4. SMTP / Discord / Windows API keys on portal-secrets
# Terraform already wrote BETTER_AUTH_SECRET and MICROSOFT_*. Add SMTP_*,
# DISCORD_*, and WINDOWS_API_* from k8s/secret.example.yaml; do not replace
# the Secret. Skip DATABASE_URL. The chart reads it from CloudNativePG
# (portal-db-app). Discord linking is optional: omit DISCORD_CLIENT_ID to hide
# the UI. Register redirect URIs `{origin}/api/auth/callback/discord` and
# `{origin}/signup/discord/callback` on the Discord application. No bot is
# required for link/unlink.
# 5. Portal + worker + alumni digest + CloudNativePG Cluster
helm upgrade --install acm-portal ./helm/acm-member-portal \
--namespace acm-portal --create-namespace
kubectl wait --for=condition=Ready --timeout=180s cluster/portal-db -n acm-portal
# 6. Entra admin consent
# After terraform apply, visit https://entra.microsoft.com → App registrations →
# acm-member-portal-dev → API permissions → "Grant admin consent for <tenant>".
# Required because the openid / profile / email scopes are requested at runtime
# by better-auth's Microsoft provider but not declared in the app registration's
# required_resource_access (admin-consent is the standard path).
# 7. Verify
kubectl get pods -n acm-portal
kubectl wait --for=condition=Ready --timeout=120s deployment/portal -n acm-portal
curl -fsS https://portal.acmuic.org/healthzThe first pod's migrate init container applies drizzle/0000_initial.sql via
scripts/migrate.ts (advisory-locked, idempotent).
The chart deploys a CloudNativePG Cluster named portal-db when
database.enabled is true (the default). To use Postgres created outside the
chart, set database.enabled=false and point at a Secret that holds
DATABASE_URL:
helm upgrade --install acm-portal ./helm/acm-member-portal \
--namespace acm-portal --create-namespace \
--set database.enabled=false \
--set database.existingSecret=portal-secrets \
--set database.existingSecretUrlKey=DATABASE_URLYou can also pass database.host plus database.password (the chart builds
the URL) or database.url. See helm/acm-member-portal/values.yaml for
storage size, replica counts, origin, and the rest.
The chart's NetworkPolicy (networkPolicy.enabled, default true) applies to
portal, worker, and digest pods only. Ingress is allowed on the app port;
egress is DNS, Postgres, and public :80/:443 minus RFC1918. CloudNativePG
pods are not selected, so the operator can still manage replication.
FQDN-scoped egress to login.microsoftonline.com and the Windows API host
needs Cilium or Calico. The chart rule is a dev fallback; production should
narrow egress. For an external database on a known CIDR, set
networkPolicy.databaseCidrs. The older k8s/networkpolicy.yaml is a
namespace-wide default-deny kept as a reference.
# Zero-config local mode (no Postgres, Entra, SMTP, Discord, or Windows API):
# boots in-process PGlite, seeds officer@localhost / local-dev, stubs mail + AD.
# Discord link/unlink stays hidden until DISCORD_CLIENT_ID is set.
bun run dev # vite on :5173
# Reset local DB + stub mail: rm -rf .data
# Against a real Postgres (CloudNativePG-shaped):
# cp .env.example .env # set DATABASE_URL + auth secrets
bun run db:migrate # apply drizzle/0000_initial.sql (also runs in init container)
bun run dev
bun run check # tsc --noEmit
bun test # vitest runLocal credentials (embedded mode only):
- Officer:
officer@local.test/local-dev - After approving a signup, the member password is written under
.data/mail/ DEV_LOGIN=1enables the same email/password login against a realDATABASE_URL
The Discord buttons stay hidden until DISCORD_CLIENT_ID and
DISCORD_CLIENT_SECRET are set. Vite loads .env from the repo root
(gitignored). Leave DATABASE_URL unset if you still want PGlite. Copying
.env.example as-is fills DATABASE_URL and the app will try Postgres on
localhost instead.
# Only the Discord bits. No DATABASE_URL.
cat >> .env <<'EOF'
DISCORD_CLIENT_ID=your_app_id
DISCORD_CLIENT_SECRET=your_app_secret
DISCORD_GUILD_ID=your_server_snowflake
ORIGIN=http://localhost:5173
BETTER_AUTH_URL=http://localhost:5173
EOFRestart the dev server after changing .env. src/lib/auth.ts reads
DISCORD_* at load time.
Create an application at discord.com/developers/applications. A throwaway local app is fine. You do not need the production ACM app, a bot token, or a bot install.
- OAuth2 → copy Client ID and Client Secret into the env vars above.
- Add both redirect URIs, exactly:
http://localhost:5173/api/auth/callback/discord(profile link)http://localhost:5173/signup/discord/callback(signup link)
DISCORD_GUILD_ID is the server snowflake. It only decides whether to show
the join CTA (https://acm.cs.uic.edu/discord). Enable Developer Mode in Discord
(User Settings → Advanced), then right-click the server → Copy Server ID. A
personal test server you can join and leave is easier than hitting ACM@UIC from
localhost.
After restart, /signup shows Link Discord, and a logged-in member sees it on
/dashboard/profile.
See windows-api/README.md. The API requires a domain-joined Windows Server,
the .NET 10 ASP.NET Core runtime, a service account with the documented AD
delegation, and the same WINDOWS_API_TOKEN value as the k8s Secret. It uses
LDAP/ADSI directly; it does not require the RSAT PowerShell module.
New members must visit /activate/ before Microsoft sign-in. They enter their
ACM username, the temporary password delivered by email or an officer, and a new
password. The server verifies and changes the password through the Windows API;
AD password-policy failures leave the account available for another attempt.
The temporary password is not a usable Microsoft password when Entra Connect
excludes passwords marked "must change at next logon". Choosing a password in AD
clears that restriction without depending on Entra SSPR or password writeback.
Members already signed in can continue changing their password from Your profile.
Activation attempts are limited across portal replicas to five per username and 100 per direct network peer in each 15-minute window. Forwarded IP headers are not trusted for this limit. Requests through an ingress can therefore share its network-peer budget. The database stores hashed limit keys and activation receipts, never passwords.
Credential delivery, AD password activation, and Microsoft password synchronization are separate steps. The signup queue completes after credentials have been delivered, not after activation. Provisioning logs show the initial password-change receipt when activation succeeds through this portal. Missing receipts mean the change is unverified: older accounts and changes made directly in AD are not retroactively detected. No page probes Entra password synchronization or promises that Microsoft sign-in is ready. After activation, allow synchronization to run before signing in; if it fails, an administrator should check the per-user Entra Connect password-sync diagnostic.
The Windows API can optionally request a directory delta cycle after provisioning and targeted password-hash synchronization after activation or a profile password change. On the Entra Connect host, enable its persistent background queue using the Windows API synchronization setup. This accelerates the existing sync process without changing portal success responses or claiming cloud sign-in readiness. It is disabled until configured.
The Windows API runbook also covers host directories and settings, service-identity checks, sync troubleshooting, live onboarding verification, and disposable test-account cleanup.
For rollout:
- Apply
0015_account_activation.sqlwith the normal migration runner before starting the updated portal or worker. It adds an activation receipt table and leaves existing account and provisioning statuses unchanged. - Deploy the compatible Windows API activation support before enabling the portal activation page. Keep the API URL and shared token configured on the portal.
- Deploy portal and worker from the same release, pinned to an immutable image digest. Verify every worker contains the email-only delivery filter. A recorded activation prevents credential reissue, including stale or alternate events for the same signup.
- Smoke-test email and manual delivery, activate a new account, confirm AD clears
pwdLastSet = 0, and verify password synchronization and Microsoft sign-in separately. Production AD/Entra behavior cannot be verified by local mock tests.
Admins and officers can open Portal status in the sidebar at
/dashboard/status. Each visit checks the Windows API's GET /healthz from
the portal server. Check again repeats the check. The user lookup calls
GET /users/{sam} with an ACM username and reports whether the account exists
in Active Directory.
Set WINDOWS_API_URL on the webui deployment and WINDOWS_API_TOKEN for user
lookups. Requests stay on the server, so the token is not sent to the browser.
Checks time out after 10 seconds. An unconfigured local API reports a
configuration error. The health endpoint confirms API connectivity; a user
lookup also checks the backend's ability to query AD.
Admins with signups.review can open Provisioning logs in the admin
sidebar at /dashboard/admin/provisioning/. Search by applicant name, NetID,
or account username, filter by provisioning status, and select View log
to see a request's history. The signup queue also links to each request's log.
The page includes completed requests, the current error, credential delivery
status, and the next automatic retry when one is scheduled. Dates include
seconds and an explicit Central time zone, with CST or CDT as appropriate.
Apply 0014_provisioning_logs.sql before deploying the web app and worker.
History records queueing, attempts, credential delivery, failures, stopped
retries, administrator retries, and completion in the same transaction as
the corresponding state change. Retries retain history even when they reset
the attempt counter. Committed attempt starts, failures, and email completion
also emit structured pod logs with a timestamp and request ID. Credentials,
account payloads, and claim tokens are excluded from diagnostics.
Earlier attempts cannot be reconstructed. Requests created before this update still show their current status and latest saved error; history begins when the updated app or worker records a transition. The page requires admin access and signup-review permission and uses private, uncached responses.
Check out the contributing guide and the Code of Conduct.
-
terraform applyexits 0;output client_idis non-empty -
kubectl get pods -n acm-portalshows portal replicas Ready -
/healthzreturns{"status":"ok"}on portal.acmuic.org - A logged-in officer can view /admin/signups (proves the Better Auth
tenant lock, the
admin.accesspermission gate, and the k8s Secret wiring) - Approving a signup writes an
audit_eventsrow and aprovisioning_eventsrow inpending; email delivery lets the worker mark itprovisionedand email the member - Approved signups stay in the signup queue until provisioning succeeds. The queue shows pending/processing status and the latest error after the first failure, including when automatic retries stop. Authorized officers can retry failed account setup. The page refreshes every five seconds while approved signups remain, without interrupting edits or actions. Successfully provisioned signups leave both the queue and the staff dashboard queue count.
- Signup approval defaults to sending credential email. Officers can instead
choose Show temporary password, copy the credentials from the applicant's
panel, and confirm they have copied them before the signup leaves the queue.
No email is sent in this mode. Closing the panel or reloading before confirmation
keeps the signup queued; showing credentials again issues a fresh temporary
password. Passwords are not stored in the outbox, audit log, or page loader.
Apply
0012_manual_credential_delivery.sqland deploy the updated worker before the web app so old workers cannot email manually approved signups. - A failed credential email keeps the signup visible. Retrying the same
event can issue a fresh temporary password and retry delivery without storing
the password. Recorded delivery survives worker restarts and prevents another
password reset or email. Apply
0011_credential_delivery_status.sqlthrough the normal migration runner and deploy the updated Windows API before the worker. - Stop existing worker replicas before applying
0013_provisioning_claims.sqland restarting them with the updated worker. Each claim has a token, and directory and email operations lock the event against reclamation. Stale workers cannot change delivery receipts or complete a newer claim. Production mail requiresSMTP_HOST; missing configuration fails delivery without writing a mail stub. - Running
kubectl create job --from=cronjob/portal-alumni-digest digest-smoke -n acm-portalsmoke-tests the digest with a no-op exit when there are no candidates