Appearance
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
.htmltemplates viatemplateUrl, never inlinetemplate:. - No raw hex colors or
pxvalues in any styles β DaisyUI classes / design tokens only. - Copy on auth surfaces says organization β never "cooperative", "Workspace", or a domain name.
snake_casein Postgres,camelCasein 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=coverageplusbunx nx run-many -t lint,test,build -p api. - Migrations:
supabase db resetre-runs all migrations +seed.sql; it needs the local stack (supabase start) and.envsourced (set -a; source .env; set +a) forGOOGLE_*(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/SECRETTask 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
β Donein 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'onpublic.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 5auth.usersinserts)
Interfaces:
Consumes:
'Pending'enum value (Task 1); existingpublic.sync_admin_allowlist_role(uuid, text).Produces:
public.is_member() returns boolean(JWT-claim based; used conceptually by later tasks); new-user default'Pending'; openhandle_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".ManagerRolesMUST 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 -5Expected: 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 -5Expected: 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_SECRETin.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 = trueAnd 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): LoginPolicytype 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,devBypassGONE)- 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.tsExpected: 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.snapKnown 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 -5Expected: 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,/pendingroute (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 -5Expected: 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 -5Expected: 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 -5Expected: 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 -5Expected: 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, afterlogin):
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'sdetectSessionInUrldoes 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; importpendingGuard):
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/unenrollvia 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(belowverifyMfa):
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.shModify:
apps/web-e2e/src/support/auth.tsModify: 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.shEdit 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 -3Expected: 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/restGetfromsupport/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(/&/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 -15Expected: 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 -3Expected: 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 onfeature/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-reviewskill ongit diff master...HEAD; write findings todocs/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"