Milestone 2: Auth and tenant identity
Purpose: Users can sign up, sign in, and be redirected into the app under a tenant slug; middleware enforces auth and tenant membership.
Exit state: Sign up creates user + tenant + membership; sign in and password reset work; /app/[slug]/** requires session and membership; /no-workspace for users with no tenants.
Spec reference: §2.1 Tenant provisioning, §2.5 User sign up and sign in, §3.13 Internationalisation (i18n), §4.3 Tenant context resolution, §4.4 Middleware stack, §5.1 Tenant, §5.2 User, §5.3 TenantMembership, §5.12 Session, §6.1 Navigation, §6.8 Error states, §6.10 Public pages, No workspace.
Prerequisites: M01 (Foundation).
2.1 Auth (Lucia)
Tasks
-
Lucia setup (
packages/author inapps/web)- Install Lucia; configure adapter for Drizzle (sessions + users).
- Session: HTTP-only, Secure, SameSite=Lax cookie; configurable expiry (e.g. 30 days sliding, max 90 days) — Section 7.2.
- Password: bcrypt (cost 12); validate min 12 chars, no complexity (Section 2.5).
-
Users and sessions tables
- Use
usersandsessionsfrom schema (M01). Session id = random string (Lucia);user_id,expires_at. - Index on
sessions.user_idfor bulk invalidation.
- Use
-
Sign up
- POST (or tRPC) signup: name, email (lowercase), password, org name.
- Create
usersrow (email_verified = true at launch); hash password; create tenant with slug from org name (unique, URL-safe); createtenant_memberships(role admin); settenants.trial_ends_at = now() + 30 days; create subscription row (plan_id trial, status trialing, inbox_limit 10); create session; set cookie; redirect to/app/[slug]/onboarding(slug = new tenant slug) — Section 6.10 signup behaviour.
-
Sign in
- POST signin: email, password. Validate credentials; create session; set cookie; redirect to
/app/[slug]/ticketswhere slug = last-used tenant (cookie/session) or first tenant by membership; if no tenants redirect to/no-workspace— Section 4.3, 6.10 login.
- POST signin: email, password. Validate credentials; create session; set cookie; redirect to
-
Sign out
- Invalidate session; clear cookie; redirect to
/login.
- Invalidate session; clear cookie; redirect to
-
Password reset
- Request: POST with email; generate token (store in DB or use signed token); send email via Postmark (template placeholder or real Section 2.10b); respond with same message whether email exists or not (no enumeration).
- Set password: GET/POST
/reset-password/[token]; validate token; show form; on submit update password, invalidate token, redirect to/login— Section 6.10.
Acceptance criteria
| Criterion | Status |
|---|---|
New user can sign up with name, email, password (12+ chars); tenant and membership created; redirected to /app/[slug]/onboarding. | ✅ |
User can sign in; session cookie set; redirect to /app/[slug]/tickets (last or first tenant). | ✅ |
User can sign out; session invalidated; redirect to /login. | ✅ |
| Password reset request sends email (or logs in dev); set-password page validates token and updates password; redirect to login. | ✅ |
| Sessions are HTTP-only, Secure, SameSite=Lax; expiry configurable. | ✅ |
2.2 Tenant resolution and middleware
Tasks
-
Middleware for
/app/[slug]/**(Section 4.4)- Read slug from pathname; look up tenant by slug; if not found → 404 or tenant-not-found page.
- Read session (Lucia); if invalid or missing → redirect to
/login?redirect=<current-url>. - Load tenant_memberships for (tenantId, userId); if none → redirect to
/loginor/no-workspace(if user has no tenants at all, use/no-workspace). - If onboarding not completed (
onboarding_completed_atnull) and path is not/app/[slug]/onboarding→ redirect to/app/[slug]/onboarding. - If subscription status is locked and path is not
/app/[slug]/locked→ redirect to/app/[slug]/locked. - Attach tenantId, userId, role to request (headers or context) for downstream (tRPC, Server Components).
-
Auth routes
/login,/signup: if user already authenticated, redirect to/app/[slug]/tickets(slug from last or first tenant)./api/auth/**: rate limit by IP (e.g. 10 req/min per IP) and by email for sign-in (e.g. 5 failed attempts per email per 15 min) to prevent targeted brute force — Section 4.4, 7.2.
-
Last-used tenant
- On visiting
/app/[slug]/**, persist slug in cookie or session (e.g.last_tenant_slug). Use for post-login redirect.
- On visiting
-
No-workspace page (Section 6.1, 6.8, 6.10)
- Route:
/no-workspace. When authenticated user has zero tenant memberships (e.g. invite-only and no invite accepted), redirect here instead of/app/.... - Page: "You don't have access to any workspace yet"; link to check email for invitations or create org; sign out.
- Middleware: after auth, if user has no memberships, redirect to
/no-workspace.
- Route:
Acceptance criteria
| Criterion | Status |
|---|---|
Unauthenticated request to /app/acme/tickets redirects to /login?redirect=.... | ✅ |
Authenticated user with no membership to tenant acme is redirected to /login (or 403). | ✅ |
Authenticated user with no tenants is redirected to /no-workspace. | ✅ |
Authenticated user with membership and incomplete onboarding is redirected to /app/[slug]/onboarding. | ✅ |
Authenticated user with membership and completed onboarding reaches /app/[slug]/tickets; tenantId/userId/role available in app context. | ✅ |
Visiting /no-workspace when user has tenants redirects to /app/[slug]/tickets. | ✅ |
2.3 Public auth pages (UI)
Tasks
-
Login page
- Form: email, password; "Forgot password?" link; "Create account" link.
- On submit: call sign-in; on success redirect; on failure show inline error. Rate limit message if applicable (IP and per-email limits per Section 7.2).
- Layout: public shell (centered card, logo) — Section 6.10.
-
Signup page
- Form: name, email, password, org name. Validation: 12 char min password; unique email and slug.
- On submit: create user + tenant + membership + trial; session; redirect to
/app/[slug]/onboarding.
-
Reset password (request)
- Form: email. Submit → send reset email; show same success message regardless of email existence.
-
Reset password (token)
- Form: new password, confirm. Validate token; on submit update password, invalidate token, redirect to login.
-
Invite accept placeholder
/invite/[token]: for M02 can show "Invalid or expired" or defer to M09; route exists.
-
i18n
- All auth page copy (labels, buttons, errors, links) from next-intl using
messages/en/auth.json(Section 3.13). No hardcoded strings on login, signup, reset-password, or no-workspace pages.
- All auth page copy (labels, buttons, errors, links) from next-intl using
Acceptance criteria
| Criterion | Status |
|---|---|
| Login and signup pages render; forms validate and submit; redirects and errors behave as specified. | ✅ |
| Reset password flow end-to-end: request → email (or stub) → set password → login. | ✅ |
| Public shell used for login, signup, reset (no sidebar/app nav). | ✅ |
2.4 E2E tests
See INDEX — Testing strategy (E2E and unit). Auth and tenant flows are critical for E2E early.
Tasks
- E2E setup and auth suite
- Add Playwright to the repo (e.g. in
apps/web); config (baseURL, test project); use a test DB or seed so tests are deterministic. - Implement E2E tests: sign up → redirect to
/app/[slug]/onboarding; sign in → redirect to/app/[slug]/tickets(or/no-workspaceif no tenants); sign out → redirect to/login; password reset (request + set password) → login; unauthenticated request to/app/[slug]/tickets→ redirect to login; user with no tenants →/no-workspace.
- Add Playwright to the repo (e.g. in
Acceptance criteria
| Criterion | Status |
|---|---|
| Playwright runs; auth and tenant E2E tests pass (or manual sign-off until automated). | Pending |
Milestone 2 sign-off
| Criterion | Status |
|---|---|
| All tasks in 2.1–2.3 complete. | ✅ |
| All acceptance criteria met. | ✅ |
E2E (or manual): sign up → onboarding redirect; sign in → tickets redirect; sign out; password reset; no-tenant user sees /no-workspace. | ✅ |
| Ready for M03 (App shell). | ✅ |