Skip to content

Auth Expansion Phase 1 Implementation Plan ​

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Open Dispatch sign-in to any Google account, GitHub, Apple (flag-off), and email+password, with a Pending membership state replacing the Workspace domain gate and a policy-driven login page.

Architecture: Supabase Auth stays the identity layer (new provider blocks + email confirmations); two migrations add a Pending role, open handle_new_user, and gate all member data behind a JWT-claim helper public.is_member() (one restrictive policy per table). The Angular app gains dedicated auth routes (/signup, /verify, /reset-password[/update], /pending, /settings/security) driven by a LoginPolicy object; the dev bypass is deleted. The Go API only learns that Pending exists.

Tech Stack: Angular 20 standalone + signals (external templates only), Supabase JS v2, DaisyUI classes, Postgres/Supabase migrations, Go (gin) libs/auth, Jest (nx test web), Playwright (apps/web-e2e), bun as runner.

Spec: docs/superpowers/specs/2026-07-14-auth-expansion-design.mdBranch: feature/auth-expansion-phase1 (already created; spec committed)

Global Constraints ​

  • Run everything with bun: bunx nx <target> β€” never npm/npx.
  • Angular components: external .html templates via templateUrl, never inline template:.
  • No raw hex colors or px values in any styles β€” DaisyUI classes / design tokens only.
  • Copy on auth surfaces says organization β€” never "cooperative", "Workspace", or a domain name.
  • snake_case in Postgres, camelCase in TS.
  • Do NOT push. Local commits only; one commit per task, message style type(scope): summary.
  • The tracking doc docs/tracking/auth-expansion-phase1.md (Task 0) must be updated (status column) in the same commit as each task.
  • Full gate before declaring done: bunx nx run-many -t lint,test,build -p web && bunx nx test web --configuration=coverage plus bunx nx run-many -t lint,test,build -p api.
  • Migrations: supabase db reset re-runs all migrations + seed.sql; it needs the local stack (supabase start) and .env sourced (set -a; source .env; set +a) for GOOGLE_* (and after Task 4, GITHUB_*).

File Structure ​

supabase/
  config.toml                                       # modify: email confirmations, github/apple blocks, redirect globs
  seed.sql                                          # modify: pin seeded users to 'Member Partner'
  migrations/20260715000000_pending_role.sql        # create: Migration A (enum value only)
  migrations/20260715000100_open_signup.sql         # create: Migration B (default, trigger, is_member, restrictive policies)
libs/auth/jwt.go                                    # modify: RolePending
libs/auth/auth_test.go                              # modify: Pending exclusion tests
apps/web/src/environments/environment.ts            # modify: auth flag, drop workspaceDomain/devBypass
apps/web/src/environments/environment.prod.ts       # modify: same shape
apps/web/src/app/core/auth/roles.ts                 # modify: 'Pending' in Role union
apps/web/src/app/core/auth/login-policy.ts          # create: LoginPolicy + platform default
apps/web/src/app/core/auth/auth.service.ts          # modify: providers, password methods, MFA, no bypass
apps/web/src/app/core/auth/auth.service.spec.ts     # modify: new method coverage
apps/web/src/app/core/auth/member.guard.ts          # create: memberGuard + pendingGuard
apps/web/src/app/core/auth/member.guard.spec.ts     # create
apps/web/src/app/app.routes.ts                      # modify: new routes, memberGuard
apps/web/src/app/features/login/login.{ts,html,spec.ts}          # rewrite: policy-driven + password + MFA challenge
apps/web/src/app/features/auth-callback/auth-callback.ts         # modify: role-aware redirect
apps/web/src/app/features/signup/signup.{ts,html,spec.ts}        # create
apps/web/src/app/features/verify/verify.{ts,html,spec.ts}        # create
apps/web/src/app/features/reset-password/reset-request.{ts,html,spec.ts}   # create
apps/web/src/app/features/reset-password/reset-update.{ts,html,spec.ts}    # create
apps/web/src/app/features/pending/pending.{ts,html,spec.ts}      # create
apps/web/src/app/features/settings/security.{ts,html,spec.ts}    # create (MFA)
scripts/dev-principal-seed.sh                       # rename from dev-principal-bypass.sh
apps/web-e2e/src/support/mail.ts                    # create: mail-catcher link fetcher
apps/web-e2e/src/support/auth.ts                    # modify: default new users to 'Member Partner'
apps/web-e2e/src/auth-flows.spec.ts                 # create: signup/verify/pending + reset e2e
docs/tracking/auth-expansion-phase1.md              # create: Linear stand-in tracking doc
.env / .env.example                                 # modify: GITHUB_CLIENT_ID/SECRET

Task 0: Tracking document (Linear stand-in) ​

Linear is at its free-plan issue cap, so work items are tracked in-repo.

Files:

  • Create: docs/tracking/auth-expansion-phase1.md

Interfaces:

  • Produces: one row per task below; every later task flips its row to βœ… Done in its commit.

  • [ ] Step 1: Write the tracking doc

markdown
# Tracking β€” Auth expansion Phase 1 (Linear stand-in)

> Linear (team gravitas) is at the free-plan issue cap; this doc stands in for
> the issue set. One row per work item. Update the Status column in the same
> commit that completes the item. When Linear frees up, port rows to issues
> and link them here.

**Spec:** `docs/superpowers/specs/2026-07-14-auth-expansion-design.md`
**Plan:** `docs/superpowers/plans/2026-07-14-auth-expansion-phase1.md`
**Branch:** `feature/auth-expansion-phase1`

| # | Work item | Status |
| - | --- | --- |
| 1 | Migration A β€” `Pending` enum value | ⬜ Todo |
| 2 | Migration B β€” open signup, `is_member()`, restrictive RLS, seed fix | ⬜ Todo |
| 3 | Go `libs/auth` β€” `RolePending` + exclusion tests | ⬜ Todo |
| 4 | Supabase config β€” GitHub/Apple blocks, email confirmations, env docs | ⬜ Todo |
| 5 | Web core β€” LoginPolicy, environments, AuthService rework (no bypass) | ⬜ Todo |
| 6 | Guards + routes β€” memberGuard/pendingGuard, role-aware callback | ⬜ Todo |
| 7 | Login page β€” policy-driven buttons, password panel, MFA challenge | ⬜ Todo |
| 8 | Signup page | ⬜ Todo |
| 9 | Verify page | ⬜ Todo |
| 10 | Reset-password pages (request + update) | ⬜ Todo |
| 11 | Pending page | ⬜ Todo |
| 12 | MFA scaffold β€” /settings/security | ⬜ Todo |
| 13 | Dev-bypass removal + seed script rename + e2e support update | ⬜ Todo |
| 14 | E2E β€” signupβ†’verifyβ†’pending, reset flow, mail helper | ⬜ Todo |
| 15 | Full gate + review record + STATUS notes | ⬜ Todo |

## Ops checklist (user-run, not code β€” mirrors spec Β§4.6)

- [ ] Google consent screen Internal β†’ External
- [ ] GitHub OAuth app (prod) + Dashboard provider config
- [ ] GitHub OAuth app (local) β†’ `GITHUB_CLIENT_ID/SECRET` in `.env`
- [ ] SMTP provider on hosted Supabase
- [ ] Apple Developer account β†’ credentials β†’ flip `appleEnabled`
  • [ ] Step 2: Commit
bash
git add docs/tracking/auth-expansion-phase1.md
git commit -m "docs(tracking): auth expansion phase 1 work-item tracker (Linear stand-in)"

Task 1: Migration A β€” add Pending to the enum ​

Postgres cannot USE an enum value in the transaction that adds it, and every migration runs in one transaction β€” so this file holds the ADD VALUE and nothing else.

Files:

  • Create: supabase/migrations/20260715000000_pending_role.sql

Interfaces:

  • Produces: enum value 'Pending' on public.membership_role, used by Migration B and all app code.

  • [ ] Step 1: Write the migration

sql
-- Pending membership (auth expansion phase 1).
--
-- 'Pending' = authenticated but not yet part of an organization: the state
-- every self-served sign-up lands in now that the Workspace domain gate is
-- gone (see 20260715000100_open_signup.sql). This file contains ONLY the enum
-- addition: Postgres refuses to use a new enum value inside the transaction
-- that added it, and migrations each run in a single transaction.
alter type public.membership_role add value if not exists 'Pending';
  • [ ] Step 2: Apply and verify
bash
set -a; source .env; set +a
supabase db reset
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -tAc \
  "select enum_range(null::public.membership_role);"

Expected: {Member Partner,Managing Partner,Admin,Principal Partner,Pending} (order may differ; Pending present).

  • [ ] Step 3: Commit
bash
git add supabase/migrations/20260715000000_pending_role.sql
git commit -m "feat(db): add Pending membership_role enum value"

(Also flip tracker row 1 β†’ βœ… in this commit; same for every task below.)


Task 2: Migration B β€” open signup, is_member(), restrictive policies ​

Files:

  • Create: supabase/migrations/20260715000100_open_signup.sql
  • Modify: supabase/seed.sql (after the 5 auth.users inserts)

Interfaces:

  • Consumes: 'Pending' enum value (Task 1); existing public.sync_admin_allowlist_role(uuid, text).

  • Produces: public.is_member() returns boolean (JWT-claim based; used conceptually by later tasks); new-user default 'Pending'; open handle_new_user.

  • [ ] Step 1: Write the migration

sql
-- Open signup + member gate (auth expansion phase 1).
--
-- 1. New profiles default to 'Pending' (no org yet).
-- 2. handle_new_user stops rejecting non-Workspace domains β€” anyone may sign
--    up (Google any-domain / GitHub / Apple / email+password). Admin-allowlist
--    promotion is kept.
-- 3. public.is_member(): JWT-claim gate. "authenticated" no longer implies
--    "vetted member", so every member-data table gets a RESTRICTIVE policy
--    requiring it β€” existing permissive policies are unchanged (restrictive
--    policies AND onto them). public.users is deliberately excluded: Pending
--    users keep their own-row select/update (WS1 + member_self_update, whose
--    trigger already blocks role self-elevation).
--
-- JWT staleness: the claim is minted at token issue; a promotion takes effect
-- on the next refresh (≀1 h, or immediately via the /pending page's
-- "check again" button which calls supabase.auth.refreshSession()).

alter table public.users alter column membership_role set default 'Pending';

create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  insert into public.users (id, email, full_name)
  values (
    new.id,
    new.email,
    coalesce(
      new.raw_user_meta_data ->> 'full_name',
      new.raw_user_meta_data ->> 'name',
      new.raw_user_meta_data ->> 'user_name',
      split_part(coalesce(new.email, ''), '@', 1)
    )
  )
  on conflict (id) do nothing;

  perform public.sync_admin_allowlist_role(new.id, new.email);
  return new;
end;
$$;

create or replace function public.is_member()
returns boolean
language sql
stable
as $$
  select coalesce(auth.jwt() ->> 'membership_role', 'Pending')
         not in ('Pending', 'null');
$$;
revoke execute on function public.is_member() from anon, public;
grant execute on function public.is_member() to authenticated;

do $$
declare
  t text;
begin
  foreach t in array array[
    'organizations','mandates','tasks','skills','user_skills','votes',
    'vote_ballots','notifications','nodes','referrals','pulse_surveys',
    'pulse_questions','pulse_participation','pulse_responses','objectives',
    'key_results','ai_documents','ai_query_metrics','ai_settings'
  ] loop
    execute format(
      'create policy "member gate" on public.%I as restrictive for all to authenticated using (public.is_member())',
      t
    );
  end loop;
end;
$$;
  • [ ] Step 2: Pin seeded users in supabase/seed.sql

Directly after the block that inserts the five @gravitasacumen.org users into auth.users (around line 27), add:

sql
-- The open-signup default is now 'Pending'; seeded demo members must stay
-- full members or every local fixture (queues, votes, dashboards) empties.
update public.users
   set membership_role = 'Member Partner'
 where membership_role = 'Pending'
   and email like '%@gravitasacumen.org';
  • [ ] Step 3: Apply and verify the gate
bash
supabase db reset
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -tAc "
  select count(*) from pg_policies where policyname = 'member gate';
  select membership_role from public.users where email = 'sarah@gravitasacumen.org';
  select column_default from information_schema.columns
   where table_schema='public' and table_name='users' and column_name='membership_role';"

Expected: 19, Member Partner, default containing 'Pending'.

  • [ ] Step 4: Verify a stranger can now sign up and is Pending
bash
curl -s -X POST "http://127.0.0.1:54321/auth/v1/admin/users" \
  -H "apikey: $SUPABASE_SERVICE_ROLE_KEY" -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"stranger@example.com","password":"pw-test-1234","email_confirm":true,"user_metadata":{"name":"Str Anger"}}' | head -c 200
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -tAc \
  "select membership_role, full_name from public.users where email='stranger@example.com';"

Expected: user JSON (no error), then Pending|Str Anger.

  • [ ] Step 5: Commit
bash
git add supabase/migrations/20260715000100_open_signup.sql supabase/seed.sql docs/tracking/auth-expansion-phase1.md
git commit -m "feat(db): open signup with Pending default and is_member() restrictive RLS gate"

Task 3: Go β€” RolePending + exclusion tests ​

Files:

  • Modify: libs/auth/jwt.go (const block)
  • Modify: libs/auth/auth_test.go

Interfaces:

  • Produces: auth.RolePending Role = "Pending". ManagerRoles MUST NOT include it.

  • [ ] Step 1: Write the failing test

In libs/auth/auth_test.go add:

go
// Pending users are authenticated but belong to no organization yet β€” no role
// list may ever include them.
func TestPendingIsNeverPrivileged(t *testing.T) {
	for _, r := range ManagerRoles {
		if r == RolePending {
			t.Fatalf("ManagerRoles must not contain RolePending")
		}
	}
}

Also find the existing table-driven RequireRole test cases in auth_test.go (cases with a role string and an expected HTTP status) and add one row: role "Pending" expecting http.StatusForbidden, copying the exact shape of the neighbouring rejected-role row.

  • [ ] Step 2: Run to verify it fails to compile
bash
bunx nx test auth 2>&1 | tail -5

Expected: FAIL β€” undefined: RolePending.

  • [ ] Step 3: Add the constant in libs/auth/jwt.go
go
	RoleAdmin            Role = "Admin"
	// RolePending β€” authenticated but not yet part of any organization
	// (open signup). Never grant it access; it exists so the claim value
	// has a name, not so it can appear in an allow list.
	RolePending Role = "Pending"

(Add inside the existing const (...) block after RoleAdmin.)

  • [ ] Step 4: Run tests
bash
bunx nx test auth 2>&1 | tail -5

Expected: PASS.

  • [ ] Step 5: Commit
bash
git add libs/auth/jwt.go libs/auth/auth_test.go docs/tracking/auth-expansion-phase1.md
git commit -m "feat(auth): RolePending constant with privilege-exclusion tests"

Task 4: Supabase config + env docs ​

Files:

  • Modify: supabase/config.toml
  • Modify: .env.example (and mirror into your local .env)

Interfaces:

  • Produces: GitHub provider active locally (needs GITHUB_CLIENT_ID/GITHUB_SECRET in .env); email confirmations ON locally; Apple block parked.

  • [ ] Step 1: Edit supabase/config.toml

Replace the [auth] β†’ [auth.external.google] region so it reads (keep [auth.hook.custom_access_token] untouched between them):

toml
[auth]
enabled = true
site_url = "http://localhost:7080"
additional_redirect_urls = ["http://localhost:7080", "http://localhost:7080/*"]
jwt_expiry = 3600
enable_signup = true

[auth.email]
enable_signup = true
# Match hosted-prod behaviour: password signups must confirm their email.
# Local confirmation mails land in the mail catcher (http://127.0.0.1:54324).
enable_confirmations = true

And after the google block append:

toml
# GitHub OAuth app (per-developer local app; callback
# http://127.0.0.1:54321/auth/v1/callback). Values from .env β€” never commit.
[auth.external.github]
enabled = true
client_id = "env(GITHUB_CLIENT_ID)"
secret = "env(GITHUB_SECRET)"

# Sign in with Apple β€” parked until an Apple Developer membership exists.
# When enabling: fill env vars AND flip environment.auth.appleEnabled in the
# web app. Callback: http://127.0.0.1:54321/auth/v1/callback.
[auth.external.apple]
enabled = false
# client_id = "env(APPLE_CLIENT_ID)"
# secret = "env(APPLE_SECRET)"

Also update the stale comment above [auth.external.google] β€” it still describes the hosted-domain gate; replace with: # Google OIDC β€” any Google account (the Workspace domain gate was removed in the auth expansion; consent screen is External).

  • [ ] Step 2: Document the new env vars in .env.example

In the "Supabase local stack" section after GOOGLE_SECRET=, add:

bash
# GitHub OAuth app for LOCAL sign-in β€” github.com β†’ Settings β†’ Developer
# settings β†’ OAuth Apps β†’ New. Homepage http://localhost:7080, callback
# http://127.0.0.1:54321/auth/v1/callback. (Prod uses a separate app whose
# callback is https://<project-ref>.supabase.co/auth/v1/callback, configured
# in the Supabase Dashboard β†’ Auth β†’ Providers, not here.)
GITHUB_CLIENT_ID=
GITHUB_SECRET=

Add the same two lines (with real values once the OAuth app exists β€” empty is fine meanwhile; GoTrue only errors when the flow is used) to your local .env.

  • [ ] Step 3: Restart the stack and verify settings took
bash
set -a; source .env; set +a
supabase stop && supabase start
curl -s http://127.0.0.1:54321/auth/v1/settings | python3 -m json.tool | grep -E '"github"|"apple"|"mailer_autoconfirm"'

Expected: "github": true, "apple": false, "mailer_autoconfirm": false.

  • [ ] Step 4: Commit
bash
git add supabase/config.toml .env.example docs/tracking/auth-expansion-phase1.md
git commit -m "feat(supabase): github provider, parked apple block, email confirmations on"

Task 5: Web core β€” LoginPolicy, environments, AuthService rework ​

Files:

  • Create: apps/web/src/app/core/auth/login-policy.ts
  • Modify: apps/web/src/app/core/auth/roles.ts
  • Modify: apps/web/src/environments/environment.ts, environment.prod.ts
  • Modify: apps/web/src/app/core/auth/auth.service.ts
  • Modify: apps/web/src/app/core/auth/auth.service.spec.ts

Interfaces:

  • Produces (used by Tasks 6–12):

    • type LoginMethod = 'google' | 'apple' | 'github' | 'password' | 'sso'
    • interface LoginPolicy { methods: LoginMethod[]; samlDomain?: string }
    • platformLoginPolicy(appleEnabled: boolean): LoginPolicy
    • type AuthResult = { ok: true; mfaRequired?: boolean } | { ok: false; message: string }
    • AuthService.signInWithProvider(p: 'google' | 'github' | 'apple'): Promise<void>
    • AuthService.signInWithPassword(email: string, password: string): Promise<AuthResult>
    • AuthService.verifyMfa(code: string): Promise<AuthResult>
    • AuthService.signUp(name: string, email: string, password: string): Promise<AuthResult>
    • AuthService.requestPasswordReset(email: string): Promise<AuthResult>
    • AuthService.updatePassword(password: string): Promise<AuthResult>
    • AuthService.resendVerification(email: string): Promise<AuthResult>
    • AuthService.signInWithSSO(domain: string): Promise<AuthResult> (stub)
    • AuthService.refreshRole(): Promise<void>
    • environment.auth: { appleEnabled: boolean } (workspaceDomain, devAuthBypass, devBypass GONE)
    • Removed (Tasks 6–13 must not reference): canDevBypass, signInAsDevManager, signInWithGoogle.
  • [ ] Step 1: roles.ts β€” add Pending

ts
export type Role =
  | 'Member Partner'
  | 'Managing Partner'
  | 'Principal Partner'
  | 'Admin'
  | 'Pending';

(Leave MANAGER_ROLES untouched; update its comment to note Pending is intentionally excluded.)

  • [ ] Step 2: Create login-policy.ts
ts
/**
 * Which sign-in methods a login screen offers. Phase 1 uses the platform
 * default; phase 2 resolves a per-organization policy from the request
 * hostname (subdomain / custom domain) and feeds it to the same login page.
 */
export type LoginMethod = 'google' | 'apple' | 'github' | 'password' | 'sso';

export interface LoginPolicy {
  methods: LoginMethod[];
  /** Set when the organization has an enterprise SAML connection (phase 2). */
  samlDomain?: string;
}

export function platformLoginPolicy(appleEnabled: boolean): LoginPolicy {
  return {
    methods: appleEnabled
      ? ['google', 'apple', 'github', 'password']
      : ['google', 'github', 'password'],
  };
}
  • [ ] Step 3: Environments

environment.ts β€” delete workspaceDomain, devAuthBypass, devBypass; add:

ts
  // Sign-in method flags for the platform LoginPolicy. Apple stays off until
  // Apple Developer credentials exist (see supabase/config.toml apple block).
  auth: { appleEnabled: false },

environment.prod.ts β€” same deletions and the same auth: { appleEnabled: false }. Do NOT touch supabaseUrl / supabaseAnonKey / apiUrl lines (the deploy inject script pattern-matches them). Afterwards verify the inject script still works:

bash
DISPATCH_SUPABASE_URL=https://x.supabase.co DISPATCH_SUPABASE_ANON_KEY=sb_publishable_test123456789 DISPATCH_API_URL=https://api.example.com node scripts/inject-web-env.mjs && git checkout apps/web/src/environments/environment.prod.ts

Expected: script exits 0 (then restore the file).

  • [ ] Step 4: Rework auth.service.ts

Delete: canDevBypass, isLoopbackHost, isLocalSupabase, signInAsDevManager, signInWithGoogle, and the environment import usages that die with them. Add below applySession (keep everything else β€” init, whenReady, applySession, signOut β€” exactly as is):

ts
  /** Human-readable message for a Supabase auth error. */
  private mapAuthError(message: string): string {
    const m = message.toLowerCase();
    if (m.includes('invalid login credentials')) {
      return 'Wrong email or password.';
    }
    if (m.includes('email not confirmed')) {
      return 'Please confirm your email first β€” check your inbox.';
    }
    if (m.includes('already registered')) {
      return 'This email already has an account. Sign in or reset your password.';
    }
    if (m.includes('password should be')) {
      return 'Password is too weak β€” use at least 8 characters.';
    }
    if (m.includes('rate limit') || m.includes('too many')) {
      return 'Too many attempts β€” wait a minute and try again.';
    }
    return message;
  }

  /** Start an OAuth flow with one of the platform providers. */
  async signInWithProvider(provider: 'google' | 'github' | 'apple'): Promise<void> {
    const origin = this.doc.defaultView?.location.origin;
    if (!origin) {
      return;
    }
    await this.supabase.auth.signInWithOAuth({
      provider,
      options: {
        redirectTo: `${origin}/auth/callback`,
        ...(provider === 'google'
          ? { queryParams: { prompt: 'select_account' } }
          : {}),
      },
    });
  }

  /**
   * Email+password sign-in. Applies the session synchronously (callers
   * navigate immediately and guards read `role`). `mfaRequired` is set when
   * the account has a verified TOTP factor still to be challenged.
   */
  async signInWithPassword(email: string, password: string): Promise<AuthResult> {
    const { data, error } = await this.supabase.auth.signInWithPassword({
      email,
      password,
    });
    if (error || !data.session) {
      return { ok: false, message: this.mapAuthError(error?.message ?? 'Sign-in failed.') };
    }
    await this.applySession(data.session);
    const { data: aal } = await this.supabase.auth.mfa.getAuthenticatorAssuranceLevel();
    const mfaRequired =
      aal?.nextLevel === 'aal2' && aal.nextLevel !== aal.currentLevel;
    return mfaRequired ? { ok: true, mfaRequired: true } : { ok: true };
  }

  /** Complete the TOTP challenge started by signInWithPassword. */
  async verifyMfa(code: string): Promise<AuthResult> {
    const { data: factors } = await this.supabase.auth.mfa.listFactors();
    const factor = factors?.totp?.[0];
    if (!factor) {
      return { ok: false, message: 'No authenticator is set up for this account.' };
    }
    const { data: challenge, error: challengeError } =
      await this.supabase.auth.mfa.challenge({ factorId: factor.id });
    if (challengeError || !challenge) {
      return { ok: false, message: this.mapAuthError(challengeError?.message ?? 'Challenge failed.') };
    }
    const { error } = await this.supabase.auth.mfa.verify({
      factorId: factor.id,
      challengeId: challenge.id,
      code,
    });
    if (error) {
      return { ok: false, message: 'That code is not valid β€” try again.' };
    }
    const { data } = await this.supabase.auth.getSession();
    await this.applySession(data.session);
    return { ok: true };
  }

  /** Create an email+password account; confirmation email is sent by Supabase. */
  async signUp(name: string, email: string, password: string): Promise<AuthResult> {
    const origin = this.doc.defaultView?.location.origin ?? '';
    const { error } = await this.supabase.auth.signUp({
      email,
      password,
      options: {
        data: { name, full_name: name },
        emailRedirectTo: `${origin}/verify`,
      },
    });
    return error ? { ok: false, message: this.mapAuthError(error.message) } : { ok: true };
  }

  async requestPasswordReset(email: string): Promise<AuthResult> {
    const origin = this.doc.defaultView?.location.origin ?? '';
    const { error } = await this.supabase.auth.resetPasswordForEmail(email, {
      redirectTo: `${origin}/reset-password/update`,
    });
    return error ? { ok: false, message: this.mapAuthError(error.message) } : { ok: true };
  }

  async updatePassword(password: string): Promise<AuthResult> {
    const { error } = await this.supabase.auth.updateUser({ password });
    return error ? { ok: false, message: this.mapAuthError(error.message) } : { ok: true };
  }

  async resendVerification(email: string): Promise<AuthResult> {
    const origin = this.doc.defaultView?.location.origin ?? '';
    const { error } = await this.supabase.auth.resend({
      type: 'signup',
      email,
      options: { emailRedirectTo: `${origin}/verify` },
    });
    return error ? { ok: false, message: this.mapAuthError(error.message) } : { ok: true };
  }

  /** Enterprise SSO β€” arrives with per-organization policies in phase 2. */
  async signInWithSSO(_domain: string): Promise<AuthResult> {
    return { ok: false, message: 'Enterprise SSO is not available yet.' };
  }

  /**
   * Force-refresh the access token and re-read the profile role. The /pending
   * page calls this so a promotion takes effect without waiting for expiry
   * (the membership_role JWT claim is minted at token issue).
   */
  async refreshRole(): Promise<void> {
    const { data } = await this.supabase.auth.refreshSession();
    await this.applySession(data.session);
  }

Add at the top of the file (below imports):

ts
/** Outcome of an auth action, with a user-displayable message on failure. */
export type AuthResult =
  | { ok: true; mfaRequired?: boolean }
  | { ok: false; message: string };

Update the class doc comment: replace the Google-Workspace sentence with "Supports platform OAuth providers (Google, GitHub, Apple when enabled) and email+password."

  • [ ] Step 5: Update auth.service.spec.ts

Delete every test that references canDevBypass / signInAsDevManager / signInWithGoogle / devBypass. Add (using the file's existing mock-Supabase pattern β€” a plain object provided via the SUPABASE token; extend that mock with auth.signInWithPassword, auth.mfa.getAuthenticatorAssuranceLevel, auth.signUp, auth.resetPasswordForEmail, auth.signInWithOAuth, auth.resend, auth.updateUser, auth.refreshSession jest.fn()s):

ts
  it('signInWithPassword maps invalid credentials to a friendly message', async () => {
    mockAuth.signInWithPassword.mockResolvedValue({
      data: { session: null },
      error: { message: 'Invalid login credentials' },
    });
    const res = await service.signInWithPassword('a@b.co', 'nope');
    expect(res).toEqual({ ok: false, message: 'Wrong email or password.' });
  });

  it('signInWithPassword applies the session and reports mfaRequired', async () => {
    mockAuth.signInWithPassword.mockResolvedValue({
      data: { session: fakeSession },
      error: null,
    });
    mockAuth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue({
      data: { currentLevel: 'aal1', nextLevel: 'aal2' },
    });
    const res = await service.signInWithPassword('a@b.co', 'pw');
    expect(res).toEqual({ ok: true, mfaRequired: true });
    expect(service.user()).toBeTruthy();
  });

  it('signUp passes the name and verify redirect', async () => {
    mockAuth.signUp.mockResolvedValue({ data: {}, error: null });
    const res = await service.signUp('Ada', 'ada@example.com', 'longenough1');
    expect(res).toEqual({ ok: true });
    expect(mockAuth.signUp).toHaveBeenCalledWith(
      expect.objectContaining({
        email: 'ada@example.com',
        options: expect.objectContaining({
          data: { name: 'Ada', full_name: 'Ada' },
        }),
      }),
    );
  });

  it('signInWithProvider only sends select_account to google', async () => {
    mockAuth.signInWithOAuth.mockResolvedValue({ data: {}, error: null });
    await service.signInWithProvider('github');
    expect(mockAuth.signInWithOAuth).toHaveBeenCalledWith(
      expect.objectContaining({ provider: 'github' }),
    );
    const opts = mockAuth.signInWithOAuth.mock.calls[0][0].options;
    expect(opts.queryParams).toBeUndefined();
  });

  it('signInWithSSO is a phase-2 stub', async () => {
    const res = await service.signInWithSSO('acme.com');
    expect(res.ok).toBe(false);
  });

(fakeSession: reuse the spec file's existing session fixture; it already has one for applySession tests.)

  • [ ] Step 6: Fix all remaining compile errors from the removals
bash
grep -rn "workspaceDomain\|devAuthBypass\|devBypass\|canDevBypass\|signInAsDevManager\|signInWithGoogle" apps/web/src | grep -v spec.snap

Known hits to fix: features/login/login.ts + login.html (fully rewritten in Task 7 β€” for THIS task just make it compile: replace auth.signInWithGoogle() with auth.signInWithProvider('google'), delete the workspaceDomain/dev-bypass members and their template bindings), app.spec.ts (uses a gravitasacumen email β€” fine, only kill it if it references removed symbols). Zero hits must remain outside features/admin/admins (that feature's DOMAIN constant is out of scope).

  • [ ] Step 7: Test and commit
bash
bunx nx test web 2>&1 | tail -5

Expected: PASS.

bash
git add -A apps/web/src docs/tracking/auth-expansion-phase1.md
git commit -m "feat(web): LoginPolicy, password/MFA auth service, drop dev bypass and domain gate"

Task 6: Guards + routes + role-aware callback ​

Files:

  • Create: apps/web/src/app/core/auth/member.guard.ts, member.guard.spec.ts
  • Modify: apps/web/src/app/app.routes.ts
  • Modify: apps/web/src/app/features/auth-callback/auth-callback.ts

Interfaces:

  • Consumes: AuthService.user()/role()/whenReady().

  • Produces: memberGuard: CanActivateFn (session + not Pending), pendingGuard: CanActivateFn (session + Pending only). Routes for Tasks 7–12 (login, signup, verify, reset-password, pending, settings/security) β€” created here pointing at components that Tasks 7–12 implement; until then the routes for not-yet-created files are added in each page's own task instead. THIS task adds only: guard swap on ''/register, /pending route (Task 11's component path), and the callback change. To keep the build green, add each route in the same task as its component.

  • [ ] Step 1: Write failing guard tests (member.guard.spec.ts)

ts
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { signal } from '@angular/core';
import { memberGuard, pendingGuard } from './member.guard';
import { AuthService } from './auth.service';

describe('memberGuard / pendingGuard', () => {
  const auth = {
    user: signal<{ id: string } | null>(null),
    role: signal<string | null>(null),
    whenReady: () => Promise.resolve(),
  };

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
    });
  });

  const run = (guard: typeof memberGuard) =>
    TestBed.runInInjectionContext(() =>
      guard({} as never, { url: '/x' } as never),
    );

  it('memberGuard sends signed-out users to /login', async () => {
    auth.user.set(null);
    const tree = await run(memberGuard);
    expect(String(tree)).toContain('/login');
  });

  it('memberGuard sends Pending users to /pending', async () => {
    auth.user.set({ id: 'u1' });
    auth.role.set('Pending');
    const tree = await run(memberGuard);
    expect(String(tree)).toContain('/pending');
  });

  it('memberGuard admits members', async () => {
    auth.user.set({ id: 'u1' });
    auth.role.set('Member Partner');
    expect(await run(memberGuard)).toBe(true);
  });

  it('pendingGuard admits only Pending users', async () => {
    auth.user.set({ id: 'u1' });
    auth.role.set('Pending');
    expect(await run(pendingGuard)).toBe(true);
    auth.role.set('Member Partner');
    const tree = await run(pendingGuard);
    expect(String(tree)).toContain('/');
  });
});
  • [ ] Step 2: Run to verify failure
bash
bunx nx test web --testPathPattern=member.guard 2>&1 | tail -5

Expected: FAIL β€” cannot find module ./member.guard.

  • [ ] Step 3: Implement member.guard.ts
ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

/**
 * Member routes: require a session AND a real membership. Pending users
 * (signed up, no organization yet) are parked on /pending.
 */
export const memberGuard: CanActivateFn = async () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  await auth.whenReady();
  if (!auth.user()) {
    return router.createUrlTree(['/login']);
  }
  return auth.role() === 'Pending' ? router.createUrlTree(['/pending']) : true;
};

/** The /pending page itself: session required, members bounced home. */
export const pendingGuard: CanActivateFn = async () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  await auth.whenReady();
  if (!auth.user()) {
    return router.createUrlTree(['/login']);
  }
  return auth.role() === 'Pending' ? true : router.createUrlTree(['/']);
};
  • [ ] Step 4: Wire routes in app.routes.ts

Swap canActivate: [authGuard] β†’ [memberGuard] on the register and '' routes (keep authGuard + roleGuard on admin). Import memberGuard from @core/auth/member.guard.

  • [ ] Step 5: Role-aware redirect + OAuth error surfacing in auth-callback.ts

Replace the constructor's afterNextRender body (providers append ?error=...&error_description=... to the redirect when the user cancels or the provider fails β€” bounce those to /login where Task 7's page displays them):

ts
    afterNextRender(async () => {
      const params = new URLSearchParams(
        this.doc.defaultView?.location.search ?? '',
      );
      const oauthError = params.get('error_description') ?? params.get('error');
      if (oauthError) {
        await this.router.navigate(['/login'], {
          queryParams: { error: oauthError },
        });
        return;
      }
      await this.waitForSession();
      await this.router.navigate([
        this.auth.role() === 'Pending' ? '/pending' : '/',
      ]);
    });

Add to the class: private readonly doc = inject(DOCUMENT); (import DOCUMENT from @angular/core alongside the existing imports).

  • [ ] Step 6: Run tests, commit
bash
bunx nx test web 2>&1 | tail -5

Expected: PASS (guard tests green; no other suite broken).

bash
git add apps/web/src/app/core/auth/member.guard.ts apps/web/src/app/core/auth/member.guard.spec.ts apps/web/src/app/app.routes.ts apps/web/src/app/features/auth-callback/auth-callback.ts docs/tracking/auth-expansion-phase1.md
git commit -m "feat(web): memberGuard/pendingGuard and role-aware auth callback"

Task 7: Login page β€” policy-driven ​

Files:

  • Rewrite: apps/web/src/app/features/login/login.ts, login.html
  • Modify: apps/web/src/app/features/login/login.spec.ts

Interfaces:

  • Consumes: platformLoginPolicy, AuthService.signInWithProvider/signInWithPassword/verifyMfa, environment.auth.appleEnabled.

  • [ ] Step 1: Failing spec (login.spec.ts β€” replace file)

ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { Login } from './login';
import { AuthService } from '@core/auth/auth.service';

describe('Login', () => {
  let fixture: ComponentFixture<Login>;
  const auth = {
    user: signal(null),
    role: signal<string | null>(null),
    signInWithProvider: jest.fn(),
    signInWithPassword: jest.fn(),
    verifyMfa: jest.fn(),
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [Login],
      providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
    }).compileComponents();
    fixture = TestBed.createComponent(Login);
    fixture.detectChanges();
  });

  it('renders google, github and password methods but not apple (flag off)', () => {
    const el: HTMLElement = fixture.nativeElement;
    expect(el.querySelector('[data-testid="oauth-google"]')).toBeTruthy();
    expect(el.querySelector('[data-testid="oauth-github"]')).toBeTruthy();
    expect(el.querySelector('[data-testid="oauth-apple"]')).toBeFalsy();
    expect(el.querySelector('[data-testid="password-form"]')).toBeTruthy();
  });

  it('shows the mapped error on failed password sign-in', async () => {
    auth.signInWithPassword.mockResolvedValue({ ok: false, message: 'Wrong email or password.' });
    const cmp = fixture.componentInstance;
    cmp.email.set('a@b.co');
    cmp.password.set('bad');
    await cmp.submitPassword();
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('Wrong email or password.');
  });

  it('switches to the MFA prompt when required', async () => {
    auth.signInWithPassword.mockResolvedValue({ ok: true, mfaRequired: true });
    const cmp = fixture.componentInstance;
    cmp.email.set('a@b.co');
    cmp.password.set('pw');
    await cmp.submitPassword();
    fixture.detectChanges();
    expect(fixture.nativeElement.querySelector('[data-testid="mfa-form"]')).toBeTruthy();
  });

  it('contains no cooperative/Workspace copy', () => {
    const text = (fixture.nativeElement.textContent ?? '').toLowerCase();
    expect(text).not.toContain('cooperative');
    expect(text).not.toContain('workspace');
  });
});
  • [ ] Step 2: Run to verify failure
bash
bunx nx test web --testPathPattern=features/login 2>&1 | tail -5

Expected: FAIL (missing testids / members).

  • [ ] Step 3: Implement login.ts
ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { AuthService } from '@core/auth/auth.service';
import { LoginMethod, platformLoginPolicy } from '@core/auth/login-policy';
import { environment } from '@env/environment';

/** Public sign-in screen. Renders whatever the LoginPolicy allows. */
@Component({
  selector: 'ds-login',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule, RouterLink],
  templateUrl: './login.html',
})
export class Login {
  protected readonly auth = inject(AuthService);
  private readonly router = inject(Router);

  // OAuth failures bounce back here as /login?error=... (see auth-callback).
  private readonly route = inject(ActivatedRoute);

  protected readonly policy = platformLoginPolicy(environment.auth.appleEnabled);
  readonly email = signal('');
  readonly password = signal('');
  readonly code = signal('');
  readonly error = signal<string | null>(
    this.route.snapshot.queryParamMap.get('error'),
  );
  readonly busy = signal(false);
  readonly mfaRequired = signal(false);

  protected has(method: LoginMethod): boolean {
    return this.policy.methods.includes(method);
  }

  protected oauth(provider: 'google' | 'github' | 'apple'): void {
    void this.auth.signInWithProvider(provider);
  }

  async submitPassword(): Promise<void> {
    this.error.set(null);
    this.busy.set(true);
    const res = await this.auth.signInWithPassword(this.email(), this.password());
    this.busy.set(false);
    if (!res.ok) {
      this.error.set(res.message);
      return;
    }
    if (res.mfaRequired) {
      this.mfaRequired.set(true);
      return;
    }
    await this.enter();
  }

  async submitMfa(): Promise<void> {
    this.error.set(null);
    this.busy.set(true);
    const res = await this.auth.verifyMfa(this.code());
    this.busy.set(false);
    if (!res.ok) {
      this.error.set(res.message);
      return;
    }
    await this.enter();
  }

  private async enter(): Promise<void> {
    await this.router.navigate([this.auth.role() === 'Pending' ? '/pending' : '/']);
  }
}
  • [ ] Step 4: Implement login.html
html
<div class="adm-login">
  <div class="adm-login-card">
    <div class="adm-login-logo">dispatch<span>.</span></div>
    <div class="adm-login-badge">Member Portal</div>
    <div class="adm-login-title">Sign in to continue</div>
    <p class="adm-login-sub">
      Dispatch is your organization's operating system for member-partners.
      Sign in with any of the methods below.
    </p>

    @if (has('google')) {
      <button class="adm-gbtn" type="button" data-testid="oauth-google" (click)="oauth('google')">
        <svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true">
          <path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
          <path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
          <path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
          <path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
        </svg>
        Continue with Google
      </button>
    }
    @if (has('github')) {
      <button class="btn btn-neutral w-full mt-2" type="button" data-testid="oauth-github" (click)="oauth('github')">
        Continue with GitHub
      </button>
    }
    @if (has('apple')) {
      <button class="btn btn-neutral w-full mt-2" type="button" data-testid="oauth-apple" (click)="oauth('apple')">
        Continue with Apple
      </button>
    }

    @if (has('password')) {
      <div class="divider text-xs opacity-60">or</div>

      @if (error()) {
        <div class="alert alert-error text-sm" role="alert">{{ error() }}</div>
      }

      @if (!mfaRequired()) {
        <form data-testid="password-form" (ngSubmit)="submitPassword()">
          <input class="input input-bordered w-full mt-2" type="email" name="email"
                 placeholder="Email" required [ngModel]="email()" (ngModelChange)="email.set($event)" />
          <input class="input input-bordered w-full mt-2" type="password" name="password"
                 placeholder="Password" required [ngModel]="password()" (ngModelChange)="password.set($event)" />
          <button class="btn btn-primary w-full mt-3" type="submit" [disabled]="busy()">
            Sign in
          </button>
        </form>
        <div class="mt-3 text-sm flex justify-between">
          <a class="link" routerLink="/signup">Create an account</a>
          <a class="link" routerLink="/reset-password">Forgot password?</a>
        </div>
      } @else {
        <form data-testid="mfa-form" (ngSubmit)="submitMfa()">
          <p class="text-sm mt-2">Enter the 6-digit code from your authenticator app.</p>
          <input class="input input-bordered w-full mt-2" type="text" name="code" inputmode="numeric"
                 autocomplete="one-time-code" placeholder="123456" required
                 [ngModel]="code()" (ngModelChange)="code.set($event)" />
          <button class="btn btn-primary w-full mt-3" type="submit" [disabled]="busy()">
            Verify
          </button>
        </form>
      }
    }

    <div class="adm-login-foot">
      By signing in you agree to your organization's member terms. All actions
      are logged to the audit trail.
    </div>
  </div>
</div>
  • [ ] Step 5: Run tests, commit
bash
bunx nx test web --testPathPattern=features/login 2>&1 | tail -5

Expected: PASS.

bash
git add apps/web/src/app/features/login docs/tracking/auth-expansion-phase1.md
git commit -m "feat(web): policy-driven login page with password and MFA panels"

Task 8: Signup page ​

Files:

  • Create: apps/web/src/app/features/signup/signup.ts, signup.html, signup.spec.ts
  • Modify: apps/web/src/app/app.routes.ts (add route)

Interfaces:

  • Consumes: AuthService.signUp.

  • [ ] Step 1: Failing spec (signup.spec.ts)

ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Signup } from './signup';
import { AuthService } from '@core/auth/auth.service';

describe('Signup', () => {
  let fixture: ComponentFixture<Signup>;
  const auth = { signUp: jest.fn(), resendVerification: jest.fn() };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [Signup],
      providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
    }).compileComponents();
    fixture = TestBed.createComponent(Signup);
    fixture.detectChanges();
  });

  it('shows the check-your-email state on success', async () => {
    auth.signUp.mockResolvedValue({ ok: true });
    const cmp = fixture.componentInstance;
    cmp.name.set('Ada');
    cmp.email.set('ada@example.com');
    cmp.password.set('longenough1');
    await cmp.submit();
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('Check your email');
  });

  it('surfaces signup errors', async () => {
    auth.signUp.mockResolvedValue({ ok: false, message: 'This email already has an account. Sign in or reset your password.' });
    const cmp = fixture.componentInstance;
    cmp.name.set('Ada');
    cmp.email.set('ada@example.com');
    cmp.password.set('longenough1');
    await cmp.submit();
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('already has an account');
  });
});
  • [ ] Step 2: Run to verify failure β€” bunx nx test web --testPathPattern=features/signup β†’ FAIL (module not found).

  • [ ] Step 3: Implement signup.ts

ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AuthService } from '@core/auth/auth.service';

/** Email+password account creation. OAuth users never see this page. */
@Component({
  selector: 'ds-signup',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule, RouterLink],
  templateUrl: './signup.html',
})
export class Signup {
  private readonly auth = inject(AuthService);

  readonly name = signal('');
  readonly email = signal('');
  readonly password = signal('');
  readonly error = signal<string | null>(null);
  readonly busy = signal(false);
  readonly sent = signal(false);

  async submit(): Promise<void> {
    this.error.set(null);
    this.busy.set(true);
    const res = await this.auth.signUp(this.name(), this.email(), this.password());
    this.busy.set(false);
    if (!res.ok) {
      this.error.set(res.message);
      return;
    }
    this.sent.set(true);
  }

  async resend(): Promise<void> {
    await this.auth.resendVerification(this.email());
  }
}
  • [ ] Step 4: Implement signup.html
html
<div class="adm-login">
  <div class="adm-login-card">
    <div class="adm-login-logo">dispatch<span>.</span></div>
    <div class="adm-login-title">Create your account</div>

    @if (sent()) {
      <div class="alert alert-success text-sm mt-3" role="status">
        Check your email β€” we sent a confirmation link to <b>{{ email() }}</b>.
      </div>
      <button class="btn btn-ghost btn-sm w-full mt-3" type="button" (click)="resend()">
        Resend email
      </button>
    } @else {
      @if (error()) {
        <div class="alert alert-error text-sm mt-2" role="alert">{{ error() }}</div>
      }
      <form (ngSubmit)="submit()">
        <input class="input input-bordered w-full mt-2" type="text" name="name"
               placeholder="Full name" required [ngModel]="name()" (ngModelChange)="name.set($event)" />
        <input class="input input-bordered w-full mt-2" type="email" name="email"
               placeholder="Email" required [ngModel]="email()" (ngModelChange)="email.set($event)" />
        <input class="input input-bordered w-full mt-2" type="password" name="password" minlength="8"
               placeholder="Password (8+ characters)" required
               [ngModel]="password()" (ngModelChange)="password.set($event)" />
        <button class="btn btn-primary w-full mt-3" type="submit" [disabled]="busy()">
          Create account
        </button>
      </form>
    }

    <div class="mt-3 text-sm text-center">
      Already have an account? <a class="link" routerLink="/login">Sign in</a>
    </div>
  </div>
</div>
  • [ ] Step 5: Add the route (in app.routes.ts, after login):
ts
  {
    path: 'signup',
    loadComponent: () => import('./features/signup/signup').then((m) => m.Signup),
  },
  • [ ] Step 6: Test + commit
bash
bunx nx test web --testPathPattern=features/signup 2>&1 | tail -5
git add apps/web/src/app/features/signup apps/web/src/app/app.routes.ts docs/tracking/auth-expansion-phase1.md
git commit -m "feat(web): email/password signup page"

Task 9: Verify page ​

Files:

  • Create: apps/web/src/app/features/verify/verify.ts, verify.html, verify.spec.ts
  • Modify: apps/web/src/app/app.routes.ts

Interfaces:

  • Consumes: AuthService.user()/whenReady()/resendVerification. The Supabase client's detectSessionInUrl does the actual token exchange; this page just reflects the outcome.

  • [ ] Step 1: Failing spec (verify.spec.ts)

ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { Verify } from './verify';
import { AuthService } from '@core/auth/auth.service';

describe('Verify', () => {
  const auth = {
    user: signal<{ id: string } | null>(null),
    role: signal<string | null>(null),
    ready: signal(true),
    whenReady: () => Promise.resolve(),
    resendVerification: jest.fn(),
  };

  async function create(): Promise<ComponentFixture<Verify>> {
    await TestBed.configureTestingModule({
      imports: [Verify],
      providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
    }).compileComponents();
    const fixture = TestBed.createComponent(Verify);
    fixture.detectChanges();
    await fixture.whenStable();
    fixture.detectChanges();
    return fixture;
  }

  it('confirms success when a session exists', async () => {
    auth.user.set({ id: 'u1' });
    const fixture = await create();
    expect(fixture.nativeElement.textContent).toContain('Email confirmed');
  });

  it('offers resend when no session arrived', async () => {
    auth.user.set(null);
    const fixture = await create();
    expect(fixture.nativeElement.querySelector('[data-testid="resend-form"]')).toBeTruthy();
  });
});
  • [ ] Step 2: Run to verify failure β€” bunx nx test web --testPathPattern=features/verify β†’ FAIL.

  • [ ] Step 3: Implement verify.ts

ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '@core/auth/auth.service';

/**
 * Landing page for the email-confirmation link. The Supabase client exchanges
 * the URL token automatically (detectSessionInUrl); success therefore shows up
 * as a signed-in user once auth is ready.
 */
@Component({
  selector: 'ds-verify',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule, RouterLink],
  templateUrl: './verify.html',
})
export class Verify {
  protected readonly auth = inject(AuthService);
  private readonly router = inject(Router);

  readonly resolved = signal(false);
  readonly email = signal('');
  readonly resent = signal(false);

  constructor() {
    void this.auth.whenReady().then(() => this.resolved.set(true));
  }

  async proceed(): Promise<void> {
    await this.router.navigate([this.auth.role() === 'Pending' ? '/pending' : '/']);
  }

  async resend(): Promise<void> {
    await this.auth.resendVerification(this.email());
    this.resent.set(true);
  }
}
  • [ ] Step 4: Implement verify.html
html
<div class="adm-login">
  <div class="adm-login-card">
    <div class="adm-login-logo">dispatch<span>.</span></div>

    @if (!resolved()) {
      <p class="mt-4">Confirming…</p>
    } @else if (auth.user()) {
      <div class="alert alert-success text-sm mt-3" role="status">
        Email confirmed β€” your account is active.
      </div>
      <button class="btn btn-primary w-full mt-3" type="button" (click)="proceed()">
        Continue
      </button>
    } @else {
      <div class="alert alert-warning text-sm mt-3" role="alert">
        This confirmation link is invalid or has expired.
      </div>
      <form data-testid="resend-form" (ngSubmit)="resend()">
        <input class="input input-bordered w-full mt-2" type="email" name="email"
               placeholder="Your email" required [ngModel]="email()" (ngModelChange)="email.set($event)" />
        <button class="btn btn-primary w-full mt-3" type="submit">Send a new link</button>
      </form>
      @if (resent()) {
        <p class="text-sm mt-2">Sent β€” check your inbox.</p>
      }
      <div class="mt-3 text-sm text-center">
        <a class="link" routerLink="/login">Back to sign in</a>
      </div>
    }
  </div>
</div>
  • [ ] Step 5: Route (after signup):
ts
  {
    path: 'verify',
    loadComponent: () => import('./features/verify/verify').then((m) => m.Verify),
  },
  • [ ] Step 6: Test + commit
bash
bunx nx test web --testPathPattern=features/verify 2>&1 | tail -5
git add apps/web/src/app/features/verify apps/web/src/app/app.routes.ts docs/tracking/auth-expansion-phase1.md
git commit -m "feat(web): email verification landing page"

Task 10: Reset-password pages ​

Files:

  • Create: apps/web/src/app/features/reset-password/reset-request.ts, .html, .spec.ts
  • Create: apps/web/src/app/features/reset-password/reset-update.ts, .html, .spec.ts
  • Modify: apps/web/src/app/app.routes.ts

Interfaces:

  • Consumes: AuthService.requestPasswordReset/updatePassword/user()/whenReady().

  • [ ] Step 1: Failing specs

reset-request.spec.ts:

ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ResetRequest } from './reset-request';
import { AuthService } from '@core/auth/auth.service';

describe('ResetRequest', () => {
  let fixture: ComponentFixture<ResetRequest>;
  const auth = { requestPasswordReset: jest.fn() };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ResetRequest],
      providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
    }).compileComponents();
    fixture = TestBed.createComponent(ResetRequest);
    fixture.detectChanges();
  });

  it('acknowledges the request without leaking account existence', async () => {
    auth.requestPasswordReset.mockResolvedValue({ ok: true });
    const cmp = fixture.componentInstance;
    cmp.email.set('a@b.co');
    await cmp.submit();
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('If that email has an account');
  });
});

reset-update.spec.ts:

ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { signal } from '@angular/core';
import { ResetUpdate } from './reset-update';
import { AuthService } from '@core/auth/auth.service';

describe('ResetUpdate', () => {
  let fixture: ComponentFixture<ResetUpdate>;
  const auth = {
    user: signal<{ id: string } | null>({ id: 'u1' }),
    whenReady: () => Promise.resolve(),
    updatePassword: jest.fn(),
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ResetUpdate],
      providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
    }).compileComponents();
    fixture = TestBed.createComponent(ResetUpdate);
    fixture.detectChanges();
  });

  it('rejects mismatched passwords before calling the API', async () => {
    const cmp = fixture.componentInstance;
    cmp.password.set('longenough1');
    cmp.confirm.set('different1');
    await cmp.submit();
    fixture.detectChanges();
    expect(auth.updatePassword).not.toHaveBeenCalled();
    expect(fixture.nativeElement.textContent).toContain('do not match');
  });

  it('navigates home on success', async () => {
    auth.updatePassword.mockResolvedValue({ ok: true });
    const router = TestBed.inject(Router);
    const nav = jest.spyOn(router, 'navigate').mockResolvedValue(true);
    const cmp = fixture.componentInstance;
    cmp.password.set('longenough1');
    cmp.confirm.set('longenough1');
    await cmp.submit();
    expect(nav).toHaveBeenCalledWith(['/']);
  });
});
  • [ ] Step 2: Run to verify failure β€” bunx nx test web --testPathPattern=reset- β†’ FAIL.

  • [ ] Step 3: Implement reset-request.ts

ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AuthService } from '@core/auth/auth.service';

/** Ask for a recovery link. Response copy never reveals whether the account exists. */
@Component({
  selector: 'ds-reset-request',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule, RouterLink],
  templateUrl: './reset-request.html',
})
export class ResetRequest {
  private readonly auth = inject(AuthService);
  readonly email = signal('');
  readonly sent = signal(false);

  async submit(): Promise<void> {
    await this.auth.requestPasswordReset(this.email());
    this.sent.set(true);
  }
}

reset-request.html:

html
<div class="adm-login">
  <div class="adm-login-card">
    <div class="adm-login-logo">dispatch<span>.</span></div>
    <div class="adm-login-title">Reset your password</div>

    @if (sent()) {
      <div class="alert alert-success text-sm mt-3" role="status">
        If that email has an account, a reset link is on its way.
      </div>
    } @else {
      <form (ngSubmit)="submit()">
        <input class="input input-bordered w-full mt-2" type="email" name="email"
               placeholder="Email" required [ngModel]="email()" (ngModelChange)="email.set($event)" />
        <button class="btn btn-primary w-full mt-3" type="submit">Send reset link</button>
      </form>
    }
    <div class="mt-3 text-sm text-center">
      <a class="link" routerLink="/login">Back to sign in</a>
    </div>
  </div>
</div>
  • [ ] Step 4: Implement reset-update.ts
ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '@core/auth/auth.service';

/**
 * Set a new password. The recovery link signs the user in (Supabase
 * detectSessionInUrl), so a missing session here means the link was bad.
 */
@Component({
  selector: 'ds-reset-update',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule, RouterLink],
  templateUrl: './reset-update.html',
})
export class ResetUpdate {
  protected readonly auth = inject(AuthService);
  private readonly router = inject(Router);

  readonly resolved = signal(false);
  readonly password = signal('');
  readonly confirm = signal('');
  readonly error = signal<string | null>(null);

  constructor() {
    void this.auth.whenReady().then(() => this.resolved.set(true));
  }

  async submit(): Promise<void> {
    this.error.set(null);
    if (this.password() !== this.confirm()) {
      this.error.set('Passwords do not match.');
      return;
    }
    const res = await this.auth.updatePassword(this.password());
    if (!res.ok) {
      this.error.set(res.message);
      return;
    }
    await this.router.navigate(['/']);
  }
}

reset-update.html:

html
<div class="adm-login">
  <div class="adm-login-card">
    <div class="adm-login-logo">dispatch<span>.</span></div>
    <div class="adm-login-title">Choose a new password</div>

    @if (resolved() && !auth.user()) {
      <div class="alert alert-warning text-sm mt-3" role="alert">
        This reset link is invalid or has expired.
      </div>
      <div class="mt-3 text-sm text-center">
        <a class="link" routerLink="/reset-password">Request a new one</a>
      </div>
    } @else {
      @if (error()) {
        <div class="alert alert-error text-sm mt-2" role="alert">{{ error() }}</div>
      }
      <form (ngSubmit)="submit()">
        <input class="input input-bordered w-full mt-2" type="password" name="password" minlength="8"
               placeholder="New password (8+ characters)" required
               [ngModel]="password()" (ngModelChange)="password.set($event)" />
        <input class="input input-bordered w-full mt-2" type="password" name="confirm"
               placeholder="Repeat new password" required
               [ngModel]="confirm()" (ngModelChange)="confirm.set($event)" />
        <button class="btn btn-primary w-full mt-3" type="submit">Set password</button>
      </form>
    }
  </div>
</div>
  • [ ] Step 5: Routes (after verify):
ts
  {
    path: 'reset-password',
    loadComponent: () =>
      import('./features/reset-password/reset-request').then((m) => m.ResetRequest),
  },
  {
    path: 'reset-password/update',
    loadComponent: () =>
      import('./features/reset-password/reset-update').then((m) => m.ResetUpdate),
  },
  • [ ] Step 6: Test + commit
bash
bunx nx test web --testPathPattern=reset- 2>&1 | tail -5
git add apps/web/src/app/features/reset-password apps/web/src/app/app.routes.ts docs/tracking/auth-expansion-phase1.md
git commit -m "feat(web): password reset request and update pages"

Task 11: Pending page ​

Files:

  • Create: apps/web/src/app/features/pending/pending.ts, pending.html, pending.spec.ts
  • Modify: apps/web/src/app/app.routes.ts

Interfaces:

  • Consumes: AuthService.user()/role()/refreshRole()/signOut(), pendingGuard (Task 6).

  • This page is the Phase 2 seam β€” org create/join lands here.

  • [ ] Step 1: Failing spec (pending.spec.ts)

ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { signal } from '@angular/core';
import { Pending } from './pending';
import { AuthService } from '@core/auth/auth.service';

describe('Pending', () => {
  let fixture: ComponentFixture<Pending>;
  const auth = {
    user: signal({ id: 'u1', email: 'ada@example.com' }),
    role: signal<string | null>('Pending'),
    refreshRole: jest.fn().mockResolvedValue(undefined),
    signOut: jest.fn(),
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [Pending],
      providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
    }).compileComponents();
    fixture = TestBed.createComponent(Pending);
    fixture.detectChanges();
  });

  it('explains the pending state', () => {
    expect(fixture.nativeElement.textContent).toContain('awaiting an organization');
  });

  it('check again refreshes the role and enters the app when promoted', async () => {
    const router = TestBed.inject(Router);
    const nav = jest.spyOn(router, 'navigate').mockResolvedValue(true);
    auth.refreshRole.mockImplementation(async () => auth.role.set('Member Partner'));
    await fixture.componentInstance.checkAgain();
    expect(nav).toHaveBeenCalledWith(['/']);
  });
});
  • [ ] Step 2: Run to verify failure β€” bunx nx test web --testPathPattern=features/pending β†’ FAIL.

  • [ ] Step 3: Implement pending.ts

ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '@core/auth/auth.service';

/**
 * Where authenticated users without an organization wait. Phase 2 replaces
 * the copy below with create-organization / join-by-invite flows.
 */
@Component({
  selector: 'ds-pending',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './pending.html',
})
export class Pending {
  protected readonly auth = inject(AuthService);
  private readonly router = inject(Router);
  readonly checking = signal(false);

  async checkAgain(): Promise<void> {
    this.checking.set(true);
    await this.auth.refreshRole();
    this.checking.set(false);
    if (this.auth.role() !== 'Pending') {
      await this.router.navigate(['/']);
    }
  }
}
  • [ ] Step 4: Implement pending.html
html
<div class="adm-login">
  <div class="adm-login-card">
    <div class="adm-login-logo">dispatch<span>.</span></div>
    <div class="adm-login-badge">Account created</div>
    <div class="adm-login-title">You're in β€” awaiting an organization</div>
    <p class="adm-login-sub">
      Your account (<b>{{ auth.user()?.email }}</b>) is active but doesn't
      belong to an organization yet. Ask your organization's manager to add
      you, then check again.
    </p>
    <button class="btn btn-primary w-full mt-3" type="button"
            [disabled]="checking()" (click)="checkAgain()">
      Check again
    </button>
    <button class="btn btn-ghost btn-sm w-full mt-2" type="button" (click)="auth.signOut()">
      Sign out
    </button>
  </div>
</div>
  • [ ] Step 5: Route (after reset-password/update; import pendingGuard):
ts
  {
    path: 'pending',
    canActivate: [pendingGuard],
    loadComponent: () =>
      import('./features/pending/pending').then((m) => m.Pending),
  },
  • [ ] Step 6: Test + commit
bash
bunx nx test web --testPathPattern=features/pending 2>&1 | tail -5
git add apps/web/src/app/features/pending apps/web/src/app/app.routes.ts docs/tracking/auth-expansion-phase1.md
git commit -m "feat(web): pending page β€” the awaiting-organization seam"

Task 12: MFA scaffold β€” /settings/security ​

Files:

  • Create: apps/web/src/app/features/settings/security.ts, security.html, security.spec.ts
  • Modify: apps/web/src/app/app.routes.ts
  • Modify: apps/web/src/app/core/auth/auth.service.ts (three thin MFA wrappers)

Interfaces:

  • Consumes: Supabase auth.mfa.enroll/challenge/verify/listFactors/unenroll via AuthService.

  • Produces on AuthService:

    • mfaFactors(): Promise<{ id: string; status: string }[]>
    • mfaEnroll(): Promise<{ id: string; qr: string; secret: string } | null>
    • mfaActivate(factorId: string, code: string): Promise<AuthResult>
    • mfaRemove(factorId: string): Promise<AuthResult>
  • [ ] Step 1: Add the wrappers to auth.service.ts (below verifyMfa):

ts
  /** Verified + unverified TOTP factors on the account. */
  async mfaFactors(): Promise<{ id: string; status: string }[]> {
    const { data } = await this.supabase.auth.mfa.listFactors();
    return (data?.all ?? [])
      .filter((f) => f.factor_type === 'totp')
      .map((f) => ({ id: f.id, status: f.status }));
  }

  /** Begin TOTP enrollment; returns the QR (SVG data URI) + manual secret. */
  async mfaEnroll(): Promise<{ id: string; qr: string; secret: string } | null> {
    const { data, error } = await this.supabase.auth.mfa.enroll({ factorType: 'totp' });
    if (error || !data) {
      return null;
    }
    return { id: data.id, qr: data.totp.qr_code, secret: data.totp.secret };
  }

  /** Confirm enrollment with the first authenticator code. */
  async mfaActivate(factorId: string, code: string): Promise<AuthResult> {
    const { data: challenge, error: challengeError } =
      await this.supabase.auth.mfa.challenge({ factorId });
    if (challengeError || !challenge) {
      return { ok: false, message: this.mapAuthError(challengeError?.message ?? 'Challenge failed.') };
    }
    const { error } = await this.supabase.auth.mfa.verify({
      factorId,
      challengeId: challenge.id,
      code,
    });
    return error
      ? { ok: false, message: 'That code is not valid β€” try again.' }
      : { ok: true };
  }

  async mfaRemove(factorId: string): Promise<AuthResult> {
    const { error } = await this.supabase.auth.mfa.unenroll({ factorId });
    return error ? { ok: false, message: this.mapAuthError(error.message) } : { ok: true };
  }
  • [ ] Step 2: Failing spec (security.spec.ts)
ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Security } from './security';
import { AuthService } from '@core/auth/auth.service';

describe('Security', () => {
  let fixture: ComponentFixture<Security>;
  const auth = {
    mfaFactors: jest.fn().mockResolvedValue([]),
    mfaEnroll: jest.fn(),
    mfaActivate: jest.fn(),
    mfaRemove: jest.fn(),
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [Security],
      providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
    }).compileComponents();
    fixture = TestBed.createComponent(Security);
    fixture.detectChanges();
    await fixture.whenStable();
    fixture.detectChanges();
  });

  it('offers enrollment when no factor exists', () => {
    expect(fixture.nativeElement.querySelector('[data-testid="mfa-enroll"]')).toBeTruthy();
  });

  it('shows the QR after starting enrollment', async () => {
    auth.mfaEnroll.mockResolvedValue({ id: 'f1', qr: 'data:image/svg+xml;utf8,<svg/>', secret: 'S3CRET' });
    await fixture.componentInstance.startEnroll();
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('S3CRET');
  });
});
  • [ ] Step 3: Run to verify failure β€” bunx nx test web --testPathPattern=features/settings β†’ FAIL.

  • [ ] Step 4: Implement security.ts

ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AuthService } from '@core/auth/auth.service';

/** Account security: TOTP two-factor enrollment (scaffold β€” TOTP only). */
@Component({
  selector: 'ds-security',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule],
  templateUrl: './security.html',
})
export class Security {
  private readonly auth = inject(AuthService);

  readonly factors = signal<{ id: string; status: string }[]>([]);
  readonly enrolling = signal<{ id: string; qr: string; secret: string } | null>(null);
  readonly code = signal('');
  readonly error = signal<string | null>(null);

  constructor() {
    void this.reload();
  }

  private async reload(): Promise<void> {
    this.factors.set(await this.auth.mfaFactors());
  }

  async startEnroll(): Promise<void> {
    this.error.set(null);
    this.enrolling.set(await this.auth.mfaEnroll());
    if (!this.enrolling()) {
      this.error.set('Could not start enrollment β€” try again.');
    }
  }

  async activate(): Promise<void> {
    const enrolling = this.enrolling();
    if (!enrolling) {
      return;
    }
    const res = await this.auth.mfaActivate(enrolling.id, this.code());
    if (!res.ok) {
      this.error.set(res.message);
      return;
    }
    this.enrolling.set(null);
    this.code.set('');
    await this.reload();
  }

  async remove(id: string): Promise<void> {
    await this.auth.mfaRemove(id);
    await this.reload();
  }
}
  • [ ] Step 5: Implement security.html
html
<div class="p-6 max-w-md mx-auto">
  <h1 class="text-xl font-semibold">Security</h1>
  <p class="text-sm opacity-70 mt-1">
    Two-factor authentication with an authenticator app (TOTP).
  </p>

  @if (error()) {
    <div class="alert alert-error text-sm mt-3" role="alert">{{ error() }}</div>
  }

  @if (enrolling(); as e) {
    <div class="card bg-base-200 mt-4 p-4">
      <img [src]="e.qr" alt="Authenticator QR code" class="mx-auto w-40" />
      <p class="text-sm mt-2">
        Scan with your authenticator app, or enter the secret manually:
        <code class="select-all">{{ e.secret }}</code>
      </p>
      <form (ngSubmit)="activate()">
        <input class="input input-bordered w-full mt-2" type="text" name="code" inputmode="numeric"
               autocomplete="one-time-code" placeholder="First code" required
               [ngModel]="code()" (ngModelChange)="code.set($event)" />
        <button class="btn btn-primary w-full mt-3" type="submit">Activate</button>
      </form>
    </div>
  } @else if (factors().length === 0) {
    <button class="btn btn-primary mt-4" type="button" data-testid="mfa-enroll" (click)="startEnroll()">
      Set up two-factor authentication
    </button>
  } @else {
    <ul class="mt-4">
      @for (f of factors(); track f.id) {
        <li class="flex items-center justify-between py-2 border-b border-base-300">
          <span class="text-sm">Authenticator app β€” {{ f.status }}</span>
          <button class="btn btn-ghost btn-xs" type="button" (click)="remove(f.id)">Remove</button>
        </li>
      }
    </ul>
  }
</div>
  • [ ] Step 6: Route (top-level, after pending; any signed-in user may secure their account):
ts
  {
    path: 'settings/security',
    canActivate: [authGuard],
    loadComponent: () =>
      import('./features/settings/security').then((m) => m.Security),
  },
  • [ ] Step 7: Test + commit
bash
bunx nx test web --testPathPattern="features/settings|auth.service" 2>&1 | tail -5
git add apps/web/src/app/features/settings apps/web/src/app/core/auth/auth.service.ts apps/web/src/app/app.routes.ts docs/tracking/auth-expansion-phase1.md
git commit -m "feat(web): TOTP MFA scaffold at /settings/security"

Task 13: Dev-bypass leftovers + seed script + e2e support ​

Files:

  • Rename: scripts/dev-principal-bypass.sh β†’ scripts/dev-principal-seed.sh

  • Modify: apps/web-e2e/src/support/auth.ts

  • Modify: any doc references (grep -rn "dev-principal-bypass" docs STATUS.md README.md 2>/dev/null)

  • [ ] Step 1: Rename + retitle the script

bash
git mv scripts/dev-principal-bypass.sh scripts/dev-principal-seed.sh

Edit its header comment: it is no longer a bypass β€” retitle to "Seeds a local Principal Partner for development sign-in (email/password via the normal login form)". Delete the REMOVE BEFORE PRODUCTION line and the sentence about environment.devAuthBypass / signInAsDevManager() (both no longer exist). Keep email/password/behaviour identical.

  • [ ] Step 2: e2e support β€” pin default role

In apps/web-e2e/src/support/auth.ts createUser, new users now default to Pending; existing specs expect members. Change the promotion block so a role is ALWAYS set:

ts
  await restPatch('users', `id=eq.${id}`, {
    membership_role: role ?? 'Member Partner',
  });

(Replace the existing if (role) { ... } block.) Update the function's doc comment: "the handle_new_user trigger creates a Pending profile; we promote to role (default 'Member Partner') so member fixtures keep working."

  • [ ] Step 3: Verify e2e compile + web suite
bash
bunx nx lint web-e2e 2>&1 | tail -3
bunx nx test web 2>&1 | tail -3

Expected: both PASS.

  • [ ] Step 4: Commit
bash
git add -A scripts apps/web-e2e docs docs/tracking/auth-expansion-phase1.md
git commit -m "chore(dev): rename bypass script to dev-principal-seed; e2e users default to Member Partner"

Task 14: E2E β€” signup β†’ verify β†’ pending, and reset flow ​

Requires the local stack (supabase start) and the web dev server; run with bunx nx e2e web-e2e.

Files:

  • Create: apps/web-e2e/src/support/mail.ts
  • Create: apps/web-e2e/src/auth-flows.spec.ts

Interfaces:

  • Consumes: mail catcher HTTP API on http://127.0.0.1:54324 (Mailpit on current Supabase CLI; Inbucket on older β€” helper tries both), deleteUser/restGet from support/auth.ts.

  • [ ] Step 1: Write support/mail.ts

ts
// Read confirmation / recovery links from the local Supabase mail catcher
// (http://127.0.0.1:54324). Newer CLI versions ship Mailpit, older Inbucket β€”
// try both APIs. Local-only tooling.

const MAIL_URL = process.env['E2E_MAIL_URL'] || 'http://127.0.0.1:54324';

async function latestBodyMailpit(to: string): Promise<string | null> {
  const res = await fetch(`${MAIL_URL}/api/v1/search?query=to:${encodeURIComponent(to)}&limit=1`);
  if (!res.ok) return null;
  const { messages } = (await res.json()) as { messages?: { ID: string }[] };
  if (!messages?.length) return null;
  const msg = await fetch(`${MAIL_URL}/api/v1/message/${messages[0].ID}`);
  const body = (await msg.json()) as { HTML?: string; Text?: string };
  return body.HTML || body.Text || null;
}

async function latestBodyInbucket(to: string): Promise<string | null> {
  const box = to.split('@')[0];
  const res = await fetch(`${MAIL_URL}/api/v1/mailbox/${box}`);
  if (!res.ok) return null;
  const list = (await res.json()) as { id: string }[];
  if (!list.length) return null;
  const msg = await fetch(`${MAIL_URL}/api/v1/mailbox/${box}/${list[list.length - 1].id}`);
  const body = (await msg.json()) as { body?: { html?: string; text?: string } };
  return body.body?.html || body.body?.text || null;
}

/** Poll the catcher until an auth link addressed to `to` appears. */
export async function fetchAuthLink(to: string, timeoutMs = 15000): Promise<string> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const body = (await latestBodyMailpit(to)) ?? (await latestBodyInbucket(to));
    const match = body?.match(/https?:\/\/[^\s"'<>]+\/auth\/v1\/verify[^\s"'<>]*/);
    if (match) {
      return match[0].replace(/&amp;/g, '&');
    }
    await new Promise((r) => setTimeout(r, 500));
  }
  throw new Error(`No auth email for ${to} within ${timeoutMs}ms`);
}
  • [ ] Step 2: Write auth-flows.spec.ts
ts
import { expect, test } from '@playwright/test';
import { createUser, deleteUser, restGet, signInAs } from './support/auth';
import { fetchAuthLink } from './support/mail';

test.describe('auth expansion flows', () => {
  test('password signup β†’ email verify β†’ pending page', async ({ page }) => {
    const stamp = Date.now();
    const email = `e2e-signup-${stamp}@example.com`;
    let userId: string | undefined;
    try {
      await page.goto('/signup');
      await page.getByPlaceholder('Full name').fill('E2E Signup');
      await page.getByPlaceholder('Email').fill(email);
      await page.getByPlaceholder('Password (8+ characters)').fill('e2e-password-1');
      await page.getByRole('button', { name: 'Create account' }).click();
      await expect(page.getByText('Check your email')).toBeVisible();

      const link = await fetchAuthLink(email);
      await page.goto(link);
      await expect(page).toHaveURL(/\/(verify|pending)/, { timeout: 15000 });
      if (page.url().includes('/verify')) {
        await page.getByRole('button', { name: 'Continue' }).click();
      }
      await expect(page).toHaveURL(/\/pending/);
      await expect(page.getByText('awaiting an organization')).toBeVisible();

      const rows = await restGet<{ id: string; membership_role: string }>(
        'users',
        `email=eq.${email}&select=id,membership_role`,
      );
      expect(rows[0]?.membership_role).toBe('Pending');
      userId = rows[0]?.id;
    } finally {
      await deleteUser(userId);
    }
  });

  test('member password sign-in lands in the app', async ({ page }) => {
    const stamp = Date.now();
    const email = `e2e-member-${stamp}@example.com`;
    const userId = await createUser(email, 'e2e-password-1', 'E2E Member');
    try {
      await page.goto('/login');
      await page.getByPlaceholder('Email').fill(email);
      await page.getByPlaceholder('Password').fill('e2e-password-1');
      await page.getByRole('button', { name: 'Sign in' }).click();
      await expect(page).toHaveURL(/\/$/, { timeout: 15000 });
    } finally {
      await deleteUser(userId);
    }
  });

  test('password reset end-to-end', async ({ page }) => {
    const stamp = Date.now();
    const email = `e2e-reset-${stamp}@example.com`;
    const userId = await createUser(email, 'old-password-1', 'E2E Reset');
    try {
      await page.goto('/reset-password');
      await page.getByPlaceholder('Email').fill(email);
      await page.getByRole('button', { name: 'Send reset link' }).click();
      await expect(page.getByText('If that email has an account')).toBeVisible();

      const link = await fetchAuthLink(email);
      await page.goto(link);
      await expect(page).toHaveURL(/reset-password\/update/, { timeout: 15000 });
      await page.getByPlaceholder('New password (8+ characters)').fill('new-password-1');
      await page.getByPlaceholder('Repeat new password').fill('new-password-1');
      await page.getByRole('button', { name: 'Set password' }).click();
      await expect(page).toHaveURL(/\/$/, { timeout: 15000 });
    } finally {
      await deleteUser(userId);
    }
  });
});
  • [ ] Step 3: Run the suite against the local stack
bash
set -a; source .env; set +a
supabase start
bunx nx e2e web-e2e 2>&1 | tail -15

Expected: new specs PASS; pre-existing specs still PASS (they now go through the promoted-by-default createUser). If the mail helper 404s on both APIs, open http://127.0.0.1:54324 in a browser to identify the catcher UI and fix the helper's endpoint β€” do not skip the test.

  • [ ] Step 4: Manual OAuth checklist (append to the tracking doc's ops section):
markdown
### Manual OAuth verification (needs real GITHUB_* / GOOGLE_* creds in .env)
- [ ] Google: /login β†’ Continue with Google β†’ account chooser (any domain) β†’ lands /pending (new) or / (member)
- [ ] GitHub: /login β†’ Continue with GitHub β†’ authorize β†’ lands /pending
- [ ] Same-email linking: GitHub sign-in with an email that already has a password account β†’ single user row, two identities
  • [ ] Step 5: Commit
bash
git add apps/web-e2e docs/tracking/auth-expansion-phase1.md
git commit -m "test(e2e): signup/verify/pending, password sign-in, and reset flows"

Task 15: Full gate, review record, STATUS notes ​

  • [ ] Step 1: Full local gate
bash
bunx nx run-many -t lint,test,build -p web 2>&1 | tail -5
bunx nx test web --configuration=coverage 2>&1 | tail -8
bunx nx run-many -t lint,test,build -p api 2>&1 | tail -5
bunx nx lint web-e2e 2>&1 | tail -3

Expected: all green; coverage not below the pre-change baseline.

  • [ ] Step 2: Update design-system/STATUS.md β€” add a dated note: auth expansion phase 1 built on feature/auth-expansion-phase1 (multi-provider + Pending state), spec/plan/tracking paths, and that CLAUDE.md's Workspace-gate description is now historical.

  • [ ] Step 3: Code review β€” run the code-review skill on git diff master...HEAD; write findings to docs/reviews/2026-07-15-auth-expansion-feature-auth-expansion-phase1.md; fix all πŸ”΄ before proceeding; present the summary in chat for sign-off. Do not push β€” pushing/PR happens only after user sign-off per CLAUDE.md.

  • [ ] Step 4: Final commit

bash
git add design-system/STATUS.md docs/reviews docs/tracking/auth-expansion-phase1.md
git commit -m "docs: auth expansion phase 1 β€” review record, status notes, tracker complete"