Submatter/apps/web/app/(app)/[workspaceSlug]/settings/profile/page.tsx
Randall Stillwell 86c014cb66 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>
2026-06-02 10:07:50 -05:00

145 lines
5.7 KiB
TypeScript

"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>
);
}