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:
parent
820dae6510
commit
86c014cb66
9 changed files with 2935 additions and 1 deletions
145
apps/web/app/(app)/[workspaceSlug]/settings/profile/page.tsx
Normal file
145
apps/web/app/(app)/[workspaceSlug]/settings/profile/page.tsx
Normal 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's OAuth provider
|
||||||
|
(e.g. GitHub or Google) while signed in here. A manual verification
|
||||||
|
flow is on the roadmap.
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/web/server/lib/identity.ts
Normal file
79
apps/web/server/lib/identity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
39
apps/web/server/routers/identity.ts
Normal file
39
apps/web/server/routers/identity.ts
Normal 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;
|
||||||
24
packages/database/migrations/0005_cooing_midnight.sql
Normal file
24
packages/database/migrations/0005_cooing_midnight.sql
Normal 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;
|
||||||
2568
packages/database/migrations/meta/0005_snapshot.json
Normal file
2568
packages/database/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue