146 lines
5.7 KiB
TypeScript
146 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's OAuth provider
|
||
|
|
(e.g. GitHub or Google) while signed in here. A manual verification
|
||
|
|
flow is on the roadmap.
|
||
|
|
</footer>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|