ubiquitous-invention/apps/web/app/(app)/[workspaceSlug]/settings/profile/page.tsx

146 lines
5.7 KiB
TypeScript
Raw Normal View History

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 11:07:50 -04:00
"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>
);
}