feat(identity): schema + helpers + read-only profile UI (Task 1, part 1/2)

First half of Task-multi-email-identity. Lays down everything except the
NextAuth callback wiring, which is gated on a research subagent finishing
its survey of OAuth provider behavior for the email_verified claim
across GitHub, Google, and Authentik.

Schema (packages/database):
* New user_email_identities table colocated with `users` in users.ts.
  Columns: id, user_id (FK), email (lowercased), verified_at, source,
  created_at, last_used_at.
* Indexes: user_id, email, unique(user_id, email), and a PARTIAL unique
  index on email WHERE verified_at IS NOT NULL — a verified email
  resolves to exactly one users row globally, while unverified rows
  (none today; placeholder for the manual-verification follow-up) do
  not share the constraint.
* Drizzle relation: users.emailIdentities -> userEmailIdentities, and
  the inverse one(users) relation.
* Migration 0005 generated by db:generate, augmented with a backfill
  INSERT that seeds one source='primary' identity per existing users
  row using created_at as verified_at. Migration applied to dev DB;
  existing admin@tasks.dev user verified as 1:1 mapped.

Server (apps/web/server):
* apps/web/server/lib/identity.ts exports two pure read helpers:
  - userOwnsEmail(userId, email): boolean used by the (upcoming)
    invite-accept procedure to verify the human controls the invited
    address under any of their linked identities.
  - findUserIdByVerifiedEmail(email): the replacement for the old
    ensureUserIdByEmail lookup. Will be called from auth.ts once the
    OAuth research subagent returns.
* apps/web/server/routers/identity.ts exposes identity.listMine — a
  protected procedure returning the caller's identities ordered by
  verifiedAt desc. Cross-user identity surface is intentionally NOT
  exposed here; that lives behind the workspace-scoped autocomplete
  in Task 3 with its own tenancy fence.

UI (apps/web/app):
* New route /[workspaceSlug]/settings/profile renders a read-only
  "Linked emails" section with per-identity row (email, source badge,
  verified state, last-used relative time) plus a hint that explains
  how to add another email (sign in via that email's OAuth provider).
* Empty / loading / error states all handled. The "no identities"
  branch should never fire post-backfill but renders a friendly
  message instead of throwing.

What's NOT in this commit:
* auth.ts changes (ensureUserIdByEmail -> ensureUserIdByVerifiedEmail,
  OAuth callback identity upsert, cross-user conflict rejection).
  Waiting on subagent research to land the callback wiring correctly
  on the first try across all three providers.
* Vitest tests. The pure helpers are 10-line query shims and the
  behavior-relevant assertion is the auth callback path — easier to
  write meaningful tests once that lands.

All three CI gates green: pnpm lint (14 pre-existing warnings,
unchanged), pnpm type-check (6/6 packages), pnpm test (14/14
existing tests across @tasks/shared, @tasks/database, @tasks/ai).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-02 10:07:50 -05:00
parent 820dae6510
commit 86c014cb66
9 changed files with 2935 additions and 1 deletions

View file

@ -0,0 +1,145 @@
"use client";
import * as React from "react";
import { useSession } from "next-auth/react";
import { Loader2, Mail, ShieldCheck, ShieldAlert, UserCircle2 } from "lucide-react";
import { api } from "@/lib/trpc";
import { Skeleton } from "@/components/ui/skeleton";
const SOURCE_LABELS: Record<string, string> = {
primary: "Primary",
"oauth:github": "GitHub",
"oauth:google": "Google",
"oauth:authentik": "Authentik",
manual: "Manually verified",
};
function sourceLabel(source: string): string {
return SOURCE_LABELS[source] ?? source;
}
/**
* Best-effort "X ago" without pulling in a date library. The "Linked emails"
* row only needs coarse buckets ("just now", "3d ago", "2mo ago") — we never
* surface the raw timestamp on this page, so jitter is fine.
*/
function relativeTime(when: Date | string | null | undefined): string {
if (!when) return "never";
const then = when instanceof Date ? when.getTime() : new Date(when).getTime();
const diffMs = Date.now() - then;
if (Number.isNaN(diffMs) || diffMs < 0) return "just now";
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return "just now";
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d ago`;
const mo = Math.floor(day / 30);
if (mo < 12) return `${mo}mo ago`;
const yr = Math.floor(day / 365);
return `${yr}y ago`;
}
export default function ProfileSettingsPage() {
const { data: session } = useSession();
const identitiesQuery = api.identity.listMine.useQuery();
return (
<div className="mx-auto max-w-2xl px-8 py-10">
<div className="mb-8 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<UserCircle2 className="size-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Profile</h1>
<p className="text-xs text-muted-foreground">
Personal account settings. These apply to you across every workspace.
</p>
</div>
</div>
<section className="rounded-lg border border-border bg-card p-6 shadow-sm">
<header className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold">Linked emails</h2>
<p className="mt-1 text-xs text-muted-foreground">
Workspace invites sent to any of these addresses will be accepted under
your account. Verified emails are confirmed by the provider that
supplied them — we do not trust an unverified claim.
</p>
</div>
{session?.user?.email ? (
<div className="flex items-center gap-2 rounded-md bg-muted px-2.5 py-1 text-xs text-muted-foreground">
<Mail className="size-3.5" aria-hidden />
<span>Signed in as {session.user.email}</span>
</div>
) : null}
</header>
{identitiesQuery.isLoading ? (
<ul className="space-y-2">
{[0, 1].map((i) => (
<li key={i}>
<Skeleton className="h-14 w-full" />
</li>
))}
</ul>
) : identitiesQuery.isError ? (
<p className="text-sm text-destructive" role="alert">
Could not load your linked emails. Refresh to retry.
</p>
) : (identitiesQuery.data?.length ?? 0) === 0 ? (
// Should never happen for an authenticated user (the migration backfills
// a primary identity for every existing users row) but cheaper to render
// a friendly empty state than to throw on the page.
<p className="text-sm text-muted-foreground">
No emails linked. This is unexpected — please contact support.
</p>
) : (
<ul className="space-y-2">
{identitiesQuery.data!.map((identity) => (
<li
key={identity.id}
className="flex items-center justify-between gap-4 rounded-md border border-border bg-background px-4 py-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{identity.email}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{sourceLabel(identity.source)}
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Last used {relativeTime(identity.lastUsedAt ?? identity.createdAt)}
</p>
</div>
{identity.verified ? (
<div className="flex items-center gap-1.5 text-xs text-emerald-600">
<ShieldCheck className="size-3.5" aria-hidden />
<span>Verified</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-xs text-amber-600">
<ShieldAlert className="size-3.5" aria-hidden />
<span>Pending</span>
</div>
)}
</li>
))}
</ul>
)}
<footer className="mt-5 rounded-md bg-muted/40 px-3 py-2 text-[11px] text-muted-foreground">
To link another email, sign in via that email&apos;s OAuth provider
(e.g. GitHub or Google) while signed in here. A manual verification
flow is on the roadmap.
</footer>
</section>
</div>
);
}

View file

@ -0,0 +1,79 @@
import { and, eq, isNotNull } from "drizzle-orm";
import { userEmailIdentities } from "@tasks/database/schema";
import { db } from "@tasks/database";
/**
* Identity helpers built on top of the `user_email_identities` table.
*
* Why this module exists: invite acceptance (and any future feature that
* binds an action to "the human who owns this email address") needs to
* answer the question *"does this `users.id` actually control this email?"*
* without leaking the wrong answer when the user signed in via a different
* provider than the invite was sent to.
*
* The answer is: *yes* iff the user has a row in `user_email_identities`
* with the lowercased email and `verified_at IS NOT NULL`. Both the
* `source='primary'` mirror of `users.email` and any OAuth-claimed or
* manually-verified identity counts.
*/
/**
* Returns `true` iff the given user owns the given (lowercased) email
* as a verified identity. Case-insensitive — callers may pass any case
* and this function normalizes.
*
* This is the single source of truth for "is this email under this
* user's control?" — invite acceptance, profile-bound API access, and
* any future per-email permission check should funnel through here.
*/
export async function userOwnsEmail(
userId: string,
email: string,
): Promise<boolean> {
const emailLower = email.trim().toLowerCase();
if (!userId || !emailLower) return false;
const rows = await db
.select({ id: userEmailIdentities.id })
.from(userEmailIdentities)
.where(
and(
eq(userEmailIdentities.userId, userId),
eq(userEmailIdentities.email, emailLower),
isNotNull(userEmailIdentities.verifiedAt),
),
)
.limit(1);
return rows.length > 0;
}
/**
* Look up the `users.id` that owns a verified email, or `null` if no
* verified identity matches. Used by the sign-in callback to resolve
* an OAuth provider's email claim to the canonical user — replacing
* the old `ensureUserIdByEmail` lookup against `users.email`.
*
* Note: this only returns matches where `verified_at IS NOT NULL`.
* The (future) "pending manual verification" rows from
* `Task-manual-email-verification` are correctly invisible here.
*/
export async function findUserIdByVerifiedEmail(
email: string,
): Promise<string | null> {
const emailLower = email.trim().toLowerCase();
if (!emailLower) return null;
const rows = await db
.select({ userId: userEmailIdentities.userId })
.from(userEmailIdentities)
.where(
and(
eq(userEmailIdentities.email, emailLower),
isNotNull(userEmailIdentities.verifiedAt),
),
)
.limit(1);
return rows[0]?.userId ?? null;
}

View file

@ -10,6 +10,7 @@ import { workspacesRouter } from "@/server/routers/workspaces";
import { typesRouter } from "@/server/routers/types"; import { typesRouter } from "@/server/routers/types";
import { formsRouter } from "@/server/routers/forms"; import { formsRouter } from "@/server/routers/forms";
import { favoritesRouter } from "@/server/routers/favorites"; import { favoritesRouter } from "@/server/routers/favorites";
import { identityRouter } from "@/server/routers/identity";
export const appRouter = router({ export const appRouter = router({
health: healthRouter, health: healthRouter,
@ -23,6 +24,7 @@ export const appRouter = router({
search: searchRouter, search: searchRouter,
forms: formsRouter, forms: formsRouter,
favorites: favoritesRouter, favorites: favoritesRouter,
identity: identityRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,39 @@
import { desc, eq } from "drizzle-orm";
import { router, protectedProcedure } from "@/server/trpc";
import { userEmailIdentities } from "@tasks/database/schema";
/**
* Procedures backing the "Linked emails" surface on the profile page and
* (eventually) the manual-verification flow. Read-only in v1.
*
* Everything is protected — only the authenticated user can see their own
* identities. We do not expose any cross-user identity lookup here; that
* surface (auto-suggest invitees by typing a name) lives in `invites.ts`
* with its own tenancy fence.
*/
export const identityRouter = router({
listMine: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session!.user.id;
const rows = await ctx.db
.select({
id: userEmailIdentities.id,
email: userEmailIdentities.email,
source: userEmailIdentities.source,
verifiedAt: userEmailIdentities.verifiedAt,
createdAt: userEmailIdentities.createdAt,
lastUsedAt: userEmailIdentities.lastUsedAt,
})
.from(userEmailIdentities)
.where(eq(userEmailIdentities.userId, userId))
.orderBy(desc(userEmailIdentities.verifiedAt));
return rows.map((row) => ({
...row,
verified: row.verifiedAt !== null,
}));
}),
});
export type IdentityRouter = typeof identityRouter;

View file

@ -0,0 +1,24 @@
CREATE TABLE "user_email_identities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"email" varchar(255) NOT NULL,
"verified_at" timestamp with time zone,
"source" varchar(30) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "user_email_identities" ADD CONSTRAINT "user_email_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "user_email_identities_user_id_idx" ON "user_email_identities" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "user_email_identities_email_idx" ON "user_email_identities" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX "user_email_identities_user_id_email_unique" ON "user_email_identities" USING btree ("user_id","email");--> statement-breakpoint
CREATE UNIQUE INDEX "user_email_identities_verified_email_unique" ON "user_email_identities" USING btree ("email") WHERE "user_email_identities"."verified_at" IS NOT NULL;--> statement-breakpoint
-- Backfill: every existing user gets a `source='primary'` identity with
-- their `users.email` (lowercased). We trust existing rows because they
-- came in via our own sign-up/credentials flow, so `verified_at` is set
-- to `created_at`. This is what makes the new
-- `ensureUserIdByVerifiedEmail` lookup return the same `users.id` that
-- `ensureUserIdByEmail` used to return for every pre-existing user.
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 ("user_id", "email") DO NOTHING;

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,13 @@
"when": 1779987416637, "when": 1779987416637,
"tag": "0004_medical_blob", "tag": "0004_medical_blob",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1780412597413,
"tag": "0005_cooing_midnight",
"breakpoints": true
} }
] ]
} }

View file

@ -8,7 +8,7 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { objects, objectAssignees, workspaceMembers } from "./objects"; import { objects, objectAssignees, workspaceMembers } from "./objects";
import { users, accounts, sessions } from "./users"; import { users, accounts, sessions, userEmailIdentities } from "./users";
import { propertyDefinitions } from "./properties"; import { propertyDefinitions } from "./properties";
import { propertyValues } from "./values"; import { propertyValues } from "./values";
import { views } from "./views"; import { views } from "./views";
@ -52,8 +52,19 @@ export const usersRelations = relations(users, ({ many }) => ({
objectAssignees: many(objectAssignees), objectAssignees: many(objectAssignees),
accounts: many(accounts), accounts: many(accounts),
sessions: many(sessions), sessions: many(sessions),
emailIdentities: many(userEmailIdentities),
})); }));
export const userEmailIdentitiesRelations = relations(
userEmailIdentities,
({ one }) => ({
user: one(users, {
fields: [userEmailIdentities.userId],
references: [users.id],
}),
}),
);
export const workspacesRelations = relations(workspaces, ({ one, many }) => ({ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
owner: one(users, { owner: one(users, {
fields: [workspaces.ownerUserId], fields: [workspaces.ownerUserId],

View file

@ -85,3 +85,62 @@ export const verificationTokens = pgTable(
pk: primaryKey({ columns: [table.identifier, table.token] }), pk: primaryKey({ columns: [table.identifier, table.token] }),
}), }),
); );
/**
* Multi-email identity. One `users` row can own many verified emails — one
* "primary" (mirrored from `users.email` for cheap legacy lookups) plus
* any number of OAuth-claimed or manually-verified addresses.
*
* Why this exists: a user who signs in via GitHub (alice@personal) and
* later via Google (alice@gmail) would otherwise collide as two separate
* `users` rows under the old `ensureUserIdByEmail` lookup. The identity
* table is the source of truth for "which `users.id` does this email
* belong to," and the invite-accept flow uses `userOwnsEmail()` against
* it to verify that the human accepting an invite actually controls the
* invited address (under any of their linked identities, not just their
* primary one).
*
* Source values:
* - 'primary' — mirror of `users.email` for the row that
* existed at user creation.
* - 'oauth:github' — captured from a verified GitHub OAuth claim.
* - 'oauth:google' — captured from a verified Google OAuth claim.
* - 'oauth:authentik' — captured from a verified Authentik OIDC claim.
* - 'manual' — added by the user via the (future) one-time-
* code verification flow.
*
* Constraints:
* - `(user_id, email)` unique: one user can't have the same email
* twice across sources. (A second provider claiming an email that's
* already linked just bumps `last_used_at`.)
* - `email` unique WHERE `verified_at IS NOT NULL`: a verified email
* can only resolve to one `users` row globally. Unverified rows
* (none exist yet, but the column is in place for the manual-verify
* flow) don't share the constraint.
*/
export const userEmailIdentities = pgTable(
"user_email_identities",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
/** Stored lowercased. Callers are responsible for `.toLowerCase()`. */
email: varchar("email", { length: 255 }).notNull(),
verifiedAt: timestamp("verified_at", { withTimezone: true }),
source: varchar("source", { length: 30 }).notNull(),
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,
),
verifiedEmailUnique: uniqueIndex("user_email_identities_verified_email_unique")
.on(table.email)
.where(sql`${table.verifiedAt} IS NOT NULL`),
}),
);