148 lines
7 KiB
Markdown
148 lines
7 KiB
Markdown
|
|
---
|
||
|
|
kind: task
|
||
|
|
slug: multi-email-identity
|
||
|
|
title: Multi-email identity on user profile (foundation for invite-by-email)
|
||
|
|
plan_slug: multitenant-saas-hardening
|
||
|
|
epic_slug: tenant-lifecycle
|
||
|
|
status: ready
|
||
|
|
priority: P1
|
||
|
|
tenant_id: global
|
||
|
|
owner: unassigned
|
||
|
|
cursor_todo_id: null
|
||
|
|
updated_at: "2026-06-02"
|
||
|
|
---
|
||
|
|
|
||
|
|
# Task summary
|
||
|
|
|
||
|
|
Decouple application identity from a single `users.email` column. Add a `user_email_identities` table so one user can own multiple verified emails (primary + OAuth-claimed + later manual). Required so the invite-acceptance flow can correctly resolve "the human at alice@gmail.com" even when they're signed in via GitHub as alice@personal.
|
||
|
|
|
||
|
|
This is the *foundation* task for the invites convoy. Lands before `Task-workspace-invites-and-roles` because it changes the sign-in path that invite-accept depends on.
|
||
|
|
|
||
|
|
## Why now (and why not later)
|
||
|
|
|
||
|
|
The current `ensureUserIdByEmail` in `apps/web/lib/auth.ts` collapses sign-in to a case-insensitive lookup on `users.email`. Concrete consequence: a user who signs up with `alice@personal.com` via GitHub today and then signs in with `alice@gmail.com` via Google tomorrow ends up as **two separate `users` rows**, neither of which is "the user." Every multitenant feature we layer on top — invites, audit log attribution, billing — would inherit this confusion.
|
||
|
|
|
||
|
|
Doing it right once is cheaper than dragging it for the next year.
|
||
|
|
|
||
|
|
## Scope
|
||
|
|
|
||
|
|
### Schema additions
|
||
|
|
|
||
|
|
New table in `packages/database/src/schema/users.ts` (kept colocated with `users` since they're logically the same identity surface):
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export const userEmailIdentities = pgTable(
|
||
|
|
"user_email_identities",
|
||
|
|
{
|
||
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
||
|
|
userId: uuid("user_id")
|
||
|
|
.notNull()
|
||
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
||
|
|
email: varchar("email", { length: 255 }).notNull(), // stored lowercase
|
||
|
|
verifiedAt: timestamp("verified_at", { withTimezone: true }),
|
||
|
|
source: varchar("source", { length: 30 }).notNull(),
|
||
|
|
// 'primary' | 'oauth:github' | 'oauth:google' | 'oauth:authentik' | 'manual'
|
||
|
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||
|
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||
|
|
},
|
||
|
|
(table) => ({
|
||
|
|
userIdx: index("user_email_identities_user_id_idx").on(table.userId),
|
||
|
|
emailIdx: index("user_email_identities_email_idx").on(table.email),
|
||
|
|
userEmailUnique: uniqueIndex("user_email_identities_user_id_email_unique").on(
|
||
|
|
table.userId,
|
||
|
|
table.email,
|
||
|
|
),
|
||
|
|
// A verified email belongs to exactly one user globally.
|
||
|
|
verifiedEmailUnique: uniqueIndex("user_email_identities_verified_email_unique").on(
|
||
|
|
table.email,
|
||
|
|
).where(sql`verified_at IS NOT NULL`),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Migration
|
||
|
|
|
||
|
|
Generate with `pnpm db:generate`. The migration must include a **backfill** step: insert one row per existing `users` row with `source='primary'`, `email=lower(users.email)`, `verified_at=users.created_at` (we trust existing rows because we minted them).
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- inside the generated migration, after CREATE TABLE
|
||
|
|
INSERT INTO user_email_identities (user_id, email, verified_at, source, created_at)
|
||
|
|
SELECT id, lower(email), created_at, 'primary', created_at FROM users
|
||
|
|
ON CONFLICT DO NOTHING;
|
||
|
|
```
|
||
|
|
|
||
|
|
### Auth wiring
|
||
|
|
|
||
|
|
Refactor `apps/web/lib/auth.ts`:
|
||
|
|
|
||
|
|
- Rename `ensureUserIdByEmail` → `ensureUserIdByVerifiedEmail`. Lookup hits `user_email_identities WHERE email = lower(?) AND verified_at IS NOT NULL` first. On miss, create a new user + a `source='primary'` identity in one transaction.
|
||
|
|
- Add a callback hook (see research subagent recommendation — likely `signIn` or `jwt`) that, on OAuth sign-in where `profile.email_verified === true`, upserts a `source='oauth:<provider>'` identity for that user. If the email is already a verified identity belonging to a *different* user → reject the sign-in with a clear error.
|
||
|
|
- Bump `last_used_at` on the identity row that actually authenticated this session.
|
||
|
|
|
||
|
|
### Server-side helper
|
||
|
|
|
||
|
|
Export `userOwnsEmail(userId: string, emailLower: string): Promise<boolean>` from `apps/web/server/lib/identity.ts` (new file). Returns true iff `user_email_identities` has a row with that user + lowercased email + `verified_at IS NOT NULL`. This is the function `Task-workspace-invites-and-roles` will call.
|
||
|
|
|
||
|
|
### Profile UI
|
||
|
|
|
||
|
|
Add a "Linked emails" section to the profile/settings page. Lists each identity with:
|
||
|
|
|
||
|
|
- Email (lowercased display)
|
||
|
|
- Source badge (`Primary`, `GitHub`, `Google`, `Authentik`, `Manual`)
|
||
|
|
- Verified state (icon if verified, "Pending verification" otherwise)
|
||
|
|
- Last used (relative time)
|
||
|
|
|
||
|
|
**Read-only in this task.** Adding a "Disconnect" button is `Task-disconnect-linked-email` (filed separately because it has destructive edge cases — disconnecting your last verified email locks you out).
|
||
|
|
|
||
|
|
### Tests
|
||
|
|
|
||
|
|
Vitest is wired now, so add real assertions:
|
||
|
|
|
||
|
|
- `packages/database` or `apps/web` (test runner can land in either): identity-table CRUD round-trip.
|
||
|
|
- Sign-in callback writes a new identity on OAuth login.
|
||
|
|
- Sign-in callback rejects when the OAuth email is verified for another user.
|
||
|
|
- `userOwnsEmail` returns the right boolean across all four identity states (primary/oauth/manual/missing).
|
||
|
|
|
||
|
|
## Subtasks
|
||
|
|
|
||
|
|
- [ ] Add `user_email_identities` schema in `packages/database/src/schema/users.ts`.
|
||
|
|
- [ ] Generate migration via `pnpm db:generate`, hand-augment with the backfill `INSERT`.
|
||
|
|
- [ ] Refactor `apps/web/lib/auth.ts`: rename to `ensureUserIdByVerifiedEmail`, add OAuth identity upsert, add cross-user conflict rejection.
|
||
|
|
- [ ] Create `apps/web/server/lib/identity.ts` with `userOwnsEmail`.
|
||
|
|
- [ ] Add "Linked emails" read-only section on the profile/settings page.
|
||
|
|
- [ ] Add vitest tests for `userOwnsEmail` + callback behavior.
|
||
|
|
- [ ] Run `pnpm lint && pnpm type-check && pnpm test` clean.
|
||
|
|
- [ ] Smoke test: sign out, sign back in via the same provider, verify only one identity row (no duplicates).
|
||
|
|
|
||
|
|
## Owner or assignee
|
||
|
|
|
||
|
|
Unassigned
|
||
|
|
|
||
|
|
## Status
|
||
|
|
|
||
|
|
ready
|
||
|
|
|
||
|
|
## Estimation
|
||
|
|
|
||
|
|
M-L
|
||
|
|
|
||
|
|
## Acceptance criteria
|
||
|
|
|
||
|
|
- [ ] An existing user can sign out, sign back in via OAuth with a different verified email than their `users.email`, and end up resolved to the same `users.id`. Profile page lists both emails.
|
||
|
|
- [ ] A second user attempting to sign in via OAuth with an email that's already verified on another user is rejected with a clear error.
|
||
|
|
- [ ] `userOwnsEmail(userId, emailLower)` returns the correct boolean for all four cases (primary owned, oauth owned, manual owned, not owned).
|
||
|
|
- [ ] Existing users have one `primary` identity row each after migration.
|
||
|
|
- [ ] All three CI gates green.
|
||
|
|
|
||
|
|
## Follow-ups explicitly NOT in scope
|
||
|
|
|
||
|
|
- `Task-manual-email-verification.md` — type a new email, get a one-time code, verify.
|
||
|
|
- `Task-disconnect-linked-email.md` — unlink with safety checks (last-verified protection).
|
||
|
|
- `Task-account-merge.md` — merge two existing users who turn out to share an email.
|
||
|
|
|
||
|
|
## Links to related Epic / Plan
|
||
|
|
|
||
|
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||
|
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|
||
|
|
- Blocks: `./Task-workspace-invites-and-roles.md`, `./Task-invite-recipient-autocomplete.md`
|