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`),
}),
);