Profile #7
21
README.md
21
README.md
|
|
@ -19,6 +19,10 @@ given instance automatically becomes its administrator.
|
|||
- Per-Group markdown notes and a to-do list with a completion progress
|
||||
indicator
|
||||
- Light/dark theme (follows system by default)
|
||||
- **Per-account theme**: the theme you pick (theme menu, bottom-left) is
|
||||
saved on that account's profile, so each account keeps its own look —
|
||||
after an account toggle you land in the other account's theme. Applies
|
||||
from the first frame of each page load (no flash)
|
||||
- Installable PWA (add-to-home-screen); requires a live connection to the
|
||||
server for its database, so there's no offline mode
|
||||
- Credentials-based accounts (email + password), with an Admin page to:
|
||||
|
|
@ -28,7 +32,22 @@ given instance automatically becomes its administrator.
|
|||
below)
|
||||
- **Profile page** (`/profile`): set a first/last name and a profile photo,
|
||||
shown in the menu on the left (the photo replaces the initial-letter
|
||||
avatar when set)
|
||||
avatar when set). The account's default theme lives on the profile too —
|
||||
pick it from the theme menu (bottom-left of the sidebar); it persists
|
||||
per account, not per browser
|
||||
- **Account linking** (Profile page): link multiple accounts on the same
|
||||
server so one person can juggle more than one identity.
|
||||
- *Request Account Link* — ask to link by the other account's email;
|
||||
the request shows "awaiting confirmation" until they respond
|
||||
- *Requested Account Link* — the recipient's list of pending requests,
|
||||
with **Create Account Link**, **Deny Account Link**, or
|
||||
**Deny and Block Account Link** (blocks that account from requesting
|
||||
again; blocks can be lifted from the *Blocked Account Link Requests*
|
||||
section)
|
||||
- *Linked Accounts* — the other side of each confirmed link, with
|
||||
**Toggle** (signs this browser out and back in as that account — it
|
||||
never asks for that account's password) and **Remove Link** (either
|
||||
linked account can do it)
|
||||
|
||||
## Tech stack
|
||||
|
||||
|
|
|
|||
|
|
@ -2,19 +2,90 @@ import { redirect } from "next/navigation";
|
|||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ProfileDTO } from "@/types/profile";
|
||||
import type {
|
||||
BlockedRequesterDTO,
|
||||
LinkedAccountDTO,
|
||||
PendingLinkRequestDTO,
|
||||
ProfileDTO,
|
||||
} from "@/types/profile";
|
||||
import { ProfileForm } from "@/components/profile/profile-form";
|
||||
import { RequestLinkForm } from "@/components/profile/request-link-form";
|
||||
import { PendingLinkRequests } from "@/components/profile/pending-link-requests";
|
||||
import { LinkedAccounts } from "@/components/profile/linked-accounts";
|
||||
import { BlockedLinkRequests } from "@/components/profile/blocked-link-requests";
|
||||
|
||||
const LINK_ACCOUNT_SELECT = {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
avatar: true,
|
||||
} as const;
|
||||
|
||||
type LinkAccountRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
/** Display name for another account: profile first/last name when set,
|
||||
* falling back to the legacy sign-up name. */
|
||||
function identity(row: LinkAccountRow) {
|
||||
const full = [row.firstName, row.lastName].filter(Boolean).join(" ").trim();
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: full || row.name?.trim() || null,
|
||||
avatar: row.avatar,
|
||||
};
|
||||
}
|
||||
|
||||
function dateLabel(date: Date) {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth();
|
||||
// Defense in depth: proxy.ts already redirects unauthenticated requests,
|
||||
// but every protected data boundary should check for itself too.
|
||||
if (!session?.user) redirect("/login");
|
||||
const userId = session.user.id;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { firstName: true, lastName: true, avatar: true },
|
||||
});
|
||||
const [user, pendingRequests, links, blocks] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { name: true, firstName: true, lastName: true, avatar: true },
|
||||
}),
|
||||
// Requests addressed to this account -- the ones it can act on.
|
||||
prisma.accountLinkRequest.findMany({
|
||||
where: { toUserId: userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { fromUser: { select: LINK_ACCOUNT_SELECT } },
|
||||
}),
|
||||
// Confirmed links in either direction; the other side of the pair is
|
||||
// whichever isn't this account.
|
||||
prisma.accountLink.findMany({
|
||||
where: { OR: [{ userId }, { linkedUserId: userId }] },
|
||||
include: {
|
||||
user: { select: LINK_ACCOUNT_SELECT },
|
||||
linkedUser: { select: LINK_ACCOUNT_SELECT },
|
||||
},
|
||||
}),
|
||||
// Blocks this account issued: who is barred from requesting it.
|
||||
prisma.accountLinkBlock.findMany({
|
||||
where: { toUserId: userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { fromUser: { select: LINK_ACCOUNT_SELECT } },
|
||||
}),
|
||||
]);
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const profile: ProfileDTO = {
|
||||
|
|
@ -23,16 +94,41 @@ export default async function ProfilePage() {
|
|||
avatar: user.avatar,
|
||||
};
|
||||
|
||||
const pending: PendingLinkRequestDTO[] = pendingRequests.map((request) => ({
|
||||
requestId: request.id,
|
||||
requestedAtLabel: dateLabel(request.createdAt),
|
||||
...identity(request.fromUser),
|
||||
}));
|
||||
|
||||
const linked: LinkedAccountDTO[] = links.map((link) => ({
|
||||
linkId: link.id,
|
||||
...identity(link.userId === userId ? link.linkedUser : link.user),
|
||||
}));
|
||||
|
||||
const blocked: BlockedRequesterDTO[] = blocks.map((block) => ({
|
||||
blockId: block.id,
|
||||
blockedAtLabel: dateLabel(block.createdAt),
|
||||
...identity(block.fromUser),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="px-1">
|
||||
<h1 className="font-heading text-xl font-bold tracking-tight">Profile</h1>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Your name and photo, shown in the menu on the left.
|
||||
Your name and photo, shown in the menu on the left — and your
|
||||
links to other accounts on this server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProfileForm initial={profile} />
|
||||
|
||||
<div className="flex max-w-xl flex-col gap-4">
|
||||
<RequestLinkForm />
|
||||
{pending.length > 0 && <PendingLinkRequests requests={pending} />}
|
||||
{linked.length > 0 && <LinkedAccounts accounts={linked} />}
|
||||
{blocked.length > 0 && <BlockedLinkRequests blocks={blocked} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import { ThemeProvider } from "@/components/theme/theme-provider";
|
|||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { RegisterServiceWorker } from "@/components/pwa/register-sw";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { themeInitScript, type RawTheme } from "@/lib/themes";
|
||||
|
||||
/** UI type: Inter -- a neutral, highly legible humanist sans that reads
|
||||
* cleanly at small sizes (to-do lists, tables, nav). */
|
||||
|
|
@ -94,7 +97,28 @@ export const viewport: Viewport = {
|
|||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
// Per-user global theme (stored on the profile row; set from the theme
|
||||
// menu, see saveUserTheme in lib/actions/profile.ts). When signed in it
|
||||
// is authoritative: the no-FOUC script below applies it before first
|
||||
// paint and the ThemeProvider starts from it and persists changes back
|
||||
// -- so each account in a linked set keeps its own look in the same
|
||||
// browser across account toggles (a toggle is a full navigation, and
|
||||
// this layout re-renders for the new user). Anonymous visitors keep the
|
||||
// old localStorage-based behavior.
|
||||
const session = await auth();
|
||||
let userTheme: RawTheme | undefined;
|
||||
if (session?.user) {
|
||||
// Stored as free text; valid values are enforced by ThemeSchema
|
||||
// (lib/validation/profile.ts) at write time, so the assertion is safe
|
||||
// -- same pattern as Project.theme.
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { theme: true },
|
||||
});
|
||||
userTheme = (profile?.theme as RawTheme | null) ?? "system";
|
||||
}
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
|
|
@ -110,10 +134,13 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||
their own fonts, backgrounds, and animations.
|
||||
"system" resolves to Default/Dark by OS preference.
|
||||
No-FOUC theme restore: runs before first paint, applies the
|
||||
stored preference to <html> (class + color-scheme). */}
|
||||
right class + color-scheme to <html>. Signed-in users get
|
||||
their profile's stored theme (per account, so it survives
|
||||
account toggles); anonymous visitors get the browser's
|
||||
localStorage preference -- see themeInitScript in lib/themes.ts. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `!(function(){try{var r=document.documentElement,c=['theme-default','theme-sunset','theme-meadow','theme-honey','theme-rose','theme-lavender','theme-slate','theme-blueprint','theme-vaporwave','theme-notebook','dark','ocean','theme-pine','theme-plum','theme-midnight','theme-ember','theme-rosewood','theme-cyberpunk','theme-starfield','light'];for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}var v=null;try{v=localStorage.getItem('theme');}catch(e){}var m={default:'theme-default',sunset:'theme-sunset',meadow:'theme-meadow',honey:'theme-honey',rose:'theme-rose',lavender:'theme-lavender',slate:'theme-slate',blueprint:'theme-blueprint',vaporwave:'theme-vaporwave',notebook:'theme-notebook',dark:'dark',ocean:'ocean',pine:'theme-pine',plum:'theme-plum',midnight:'theme-midnight',ember:'theme-ember',rosewood:'theme-rosewood',cyberpunk:'theme-cyberpunk',starfield:'theme-starfield'};var d=['dark','ocean','pine','plum','midnight','ember','rosewood','cyberpunk','starfield'];if(v==='system'||!v){v=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'default';}if(m[v]){r.classList.add(m[v]);r.style.colorScheme=d.indexOf(v)>=0?'dark':'light';}}catch(e){}})();`,
|
||||
__html: themeInitScript(userTheme),
|
||||
}}
|
||||
/>
|
||||
{/* Vaporwave scenery (see app/globals.css): the outrun floor grid,
|
||||
|
|
@ -146,7 +173,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||
<svg className="vw-palm vw-palm--right-small" aria-hidden>
|
||||
<use href="#vw-palm" />
|
||||
</svg>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<ThemeProvider userTheme={userTheme}>
|
||||
<TooltipProvider delay={200}>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
|
|
|||
47
auth.ts
47
auth.ts
|
|
@ -5,7 +5,7 @@ import bcrypt from "bcryptjs";
|
|||
import { prisma } from "@/lib/db";
|
||||
import { LoginSchema } from "@/lib/validation/auth";
|
||||
import { Role } from "@/lib/generated/prisma/enums";
|
||||
import { PendingAccountSignin } from "@/lib/auth-errors";
|
||||
import { PendingAccountSignin, SwitchAccountSignin } from "@/lib/auth-errors";
|
||||
|
||||
// Credentials-only, JWT sessions, no database adapter: with a single
|
||||
// Credentials provider and no OAuth, there's nothing for a DB-backed
|
||||
|
|
@ -20,7 +20,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
signIn: "/login",
|
||||
},
|
||||
providers: [
|
||||
// Explicit id: with a second Credentials provider ("account-switch"
|
||||
// below) the providers must be disambiguated by id, and existing
|
||||
// signIn("credentials", ...) calls keep working against this one.
|
||||
Credentials({
|
||||
id: "credentials",
|
||||
credentials: {
|
||||
email: {},
|
||||
password: {},
|
||||
|
|
@ -40,6 +44,47 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
// email confirmation) and can't log in yet -- see lib/settings.ts.
|
||||
if (user.role === Role.PENDING) throw new PendingAccountSignin();
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||
},
|
||||
}),
|
||||
// Signs a user in as one of the accounts their own account is linked
|
||||
// to -- the Profile page "Toggle" button -- without knowing that
|
||||
// account's password. The only credential accepted is a one-time
|
||||
// token minted by toggleAccount (lib/actions/account-links.ts)
|
||||
// *after* verifying the link exists, so holding a token is what
|
||||
// proves eligibility; this provider just exchanges it for a session.
|
||||
// The token is consumed here (usedAt set) so a replayed
|
||||
// /api/auth/callback/account-switch request can never switch twice.
|
||||
Credentials({
|
||||
id: "account-switch",
|
||||
name: "AccountSwitch",
|
||||
credentials: {
|
||||
token: {},
|
||||
},
|
||||
async authorize(rawCredentials) {
|
||||
const token = typeof rawCredentials?.token === "string" ? rawCredentials.token : "";
|
||||
if (!token) throw new SwitchAccountSignin();
|
||||
|
||||
const row = await prisma.accountSwitchToken.findUnique({ where: { token } });
|
||||
if (!row || row.usedAt || row.expiresAt <= new Date()) {
|
||||
throw new SwitchAccountSignin();
|
||||
}
|
||||
|
||||
// Atomically claim the token. If a concurrent request already did,
|
||||
// count is 0 and this attempt fails even though the read above passed.
|
||||
const claimed = await prisma.accountSwitchToken.updateMany({
|
||||
where: { id: row.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (claimed.count === 0) throw new SwitchAccountSignin();
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: row.targetUserId } });
|
||||
if (!user) throw new SwitchAccountSignin();
|
||||
|
||||
// Same rule as the credentials provider: PENDING accounts can't
|
||||
// hold a session at all.
|
||||
if (user.role === Role.PENDING) throw new SwitchAccountSignin();
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { LinkAccountIdentity } from "@/types/profile";
|
||||
|
||||
/**
|
||||
* Photo-or-initial circle for another account (Linked Accounts, pending
|
||||
* requests, blocked requests). Same look as the signed-in user's avatar in
|
||||
* the side nav so the two read as the same kind of thing.
|
||||
*/
|
||||
export function AccountAvatar({
|
||||
account,
|
||||
sizeClass,
|
||||
}: {
|
||||
account: LinkAccountIdentity;
|
||||
sizeClass: string;
|
||||
}) {
|
||||
if (account.avatar) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatars, no image-optimization pipeline in this self-hosted app
|
||||
<img
|
||||
src={account.avatar}
|
||||
alt={account.name ?? account.email}
|
||||
className={`${sizeClass} shrink-0 rounded-full object-cover`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const letter = (account.name?.trim()[0] ?? account.email[0] ?? "?").toUpperCase();
|
||||
return (
|
||||
<span
|
||||
className={`${sizeClass} flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase`}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { AccountAvatar } from "@/components/profile/account-avatar";
|
||||
import { unblockAccountLinkRequests } from "@/lib/actions/account-links";
|
||||
import type { BlockedRequesterDTO } from "@/types/profile";
|
||||
|
||||
/**
|
||||
* Accounts this account blocked from sending link requests (via "Deny and
|
||||
* Block Account Link"). Lifting a block lets that account request again --
|
||||
* the block is permanent for them otherwise, so it must be reversible
|
||||
* here.
|
||||
*/
|
||||
export function BlockedLinkRequests({ blocks }: { blocks: BlockedRequesterDTO[] }) {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
async function handleAllow(block: BlockedRequesterDTO) {
|
||||
if (busyId) return;
|
||||
setBusyId(block.blockId);
|
||||
try {
|
||||
const result = await unblockAccountLinkRequests(block.blockId);
|
||||
if (result?.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(`${block.email} can send link requests to you again.`);
|
||||
} catch {
|
||||
toast.error("Couldn't allow requests from that account. Try again.");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Blocked Account Link Requests</CardTitle>
|
||||
<CardDescription>
|
||||
These accounts can't send link requests to this one.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{blocks.map((block) => {
|
||||
const busy = busyId === block.blockId;
|
||||
return (
|
||||
<div
|
||||
key={block.blockId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<AccountAvatar account={block} sizeClass="size-9" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{block.email}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{block.name ? `${block.name} · ` : ""}
|
||||
blocked {block.blockedAtLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAllow(block)}
|
||||
disabled={busyId !== null}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="size-3.5" />
|
||||
)}
|
||||
Allow requests
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeftRight, Loader2, Unlink } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||
import { AccountAvatar } from "@/components/profile/account-avatar";
|
||||
import { removeAccountLink, toggleAccount } from "@/lib/actions/account-links";
|
||||
import type { LinkedAccountDTO } from "@/types/profile";
|
||||
|
||||
/**
|
||||
* "Linked Accounts": the other accounts this one is linked to.
|
||||
* - "Toggle": signs this browser out and back in as that account (the
|
||||
* server verifies the link and mints a one-time switch token, so the
|
||||
* other account's password is never needed).
|
||||
* - "Remove Link": either account in the pair can do it; confirmed first.
|
||||
*/
|
||||
export function LinkedAccounts({ accounts }: { accounts: LinkedAccountDTO[] }) {
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<LinkedAccountDTO | null>(null);
|
||||
const [removingBusy, setRemovingBusy] = useState(false);
|
||||
|
||||
async function handleToggle(account: LinkedAccountDTO) {
|
||||
if (togglingId) return;
|
||||
setTogglingId(account.id);
|
||||
try {
|
||||
const result = await toggleAccount(account.id);
|
||||
if (result?.url) {
|
||||
// The action already signed this browser in as the linked account;
|
||||
// navigate so the app shell re-renders for them.
|
||||
window.location.href = result.url;
|
||||
return;
|
||||
}
|
||||
toast.error(result?.error ?? "Couldn't switch to that account.");
|
||||
} catch {
|
||||
toast.error("Couldn't switch to that account. Try again.");
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!removing) return;
|
||||
const account = removing;
|
||||
setRemoving(null);
|
||||
setRemovingBusy(true);
|
||||
try {
|
||||
const result = await removeAccountLink(account.linkId);
|
||||
if (result?.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(`Link with ${account.email} removed.`);
|
||||
} catch {
|
||||
toast.error("Couldn't remove the link. Try again.");
|
||||
} finally {
|
||||
setRemovingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Linked Accounts</CardTitle>
|
||||
<CardDescription>
|
||||
Accounts this one is linked to. Toggle signs you in as that
|
||||
account; Remove Link works from either side.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.linkId}
|
||||
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<AccountAvatar account={account} sizeClass="size-9" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{account.email}</p>
|
||||
{account.name && (
|
||||
<p className="truncate text-xs text-muted-foreground">{account.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleToggle(account)}
|
||||
disabled={togglingId !== null}
|
||||
>
|
||||
{togglingId === account.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
)}
|
||||
{togglingId === account.id ? "Switching…" : "Toggle"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={togglingId !== null}
|
||||
onClick={() => setRemoving(account)}
|
||||
>
|
||||
<Unlink className="size-3.5" />
|
||||
Remove Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={removing !== null}
|
||||
onOpenChange={(open) => !removingBusy && !open && setRemoving(null)}
|
||||
title={`Remove link with ${removing?.email}?`}
|
||||
description="Either linked account can remove the link. Removing it doesn't block anything -- the other account can still send a new link request, which you can accept again."
|
||||
confirmLabel="Remove Link"
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, ShieldBan, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||
import { AccountAvatar } from "@/components/profile/account-avatar";
|
||||
import {
|
||||
createAccountLink,
|
||||
denyAccountLink,
|
||||
denyAndBlockAccountLink,
|
||||
} from "@/lib/actions/account-links";
|
||||
import type { PendingLinkRequestDTO } from "@/types/profile";
|
||||
|
||||
type DenyIntent = { request: PendingLinkRequestDTO; block: boolean };
|
||||
|
||||
/**
|
||||
* "Requested Account Link" section: link requests this account received.
|
||||
* Only the recipient acts on these -- the requester just sees
|
||||
* "awaiting confirmation" on their own Profile page.
|
||||
*/
|
||||
export function PendingLinkRequests({ requests }: { requests: PendingLinkRequestDTO[] }) {
|
||||
// Which request row (if any) is currently processing a button press.
|
||||
const [busyRequestId, setBusyRequestId] = useState<string | null>(null);
|
||||
const [denyIntent, setDenyIntent] = useState<DenyIntent | null>(null);
|
||||
|
||||
/** Runs a request action, toasting its error (if any) and reporting
|
||||
* success so the caller can toast a specific confirmation. */
|
||||
async function run(requestId: string, action: () => Promise<{ error?: string } | undefined>) {
|
||||
setBusyRequestId(requestId);
|
||||
let failed = false;
|
||||
try {
|
||||
const result = await action();
|
||||
if (result?.error) {
|
||||
toast.error(result.error);
|
||||
failed = true;
|
||||
}
|
||||
} catch {
|
||||
toast.error("That didn't work. Try again.");
|
||||
failed = true;
|
||||
} finally {
|
||||
setBusyRequestId(null);
|
||||
}
|
||||
return !failed;
|
||||
}
|
||||
|
||||
async function handleConfirmLink(request: PendingLinkRequestDTO) {
|
||||
const ok = await run(request.requestId, () => createAccountLink(request.requestId));
|
||||
if (ok) toast.success("Account link created.");
|
||||
}
|
||||
|
||||
async function handleDeny() {
|
||||
if (!denyIntent) return;
|
||||
const { request, block } = denyIntent;
|
||||
setDenyIntent(null);
|
||||
const ok = await run(
|
||||
request.requestId,
|
||||
block
|
||||
? () => denyAndBlockAccountLink(request.requestId)
|
||||
: () => denyAccountLink(request.requestId)
|
||||
);
|
||||
if (!ok) return;
|
||||
toast.success(
|
||||
block
|
||||
? "Request denied — that account can't send link requests to you anymore."
|
||||
: "Request denied."
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Requested Account Link</CardTitle>
|
||||
<CardDescription>
|
||||
These accounts asked to link with this one. Linking lets each
|
||||
account toggle the other in.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{requests.map((request) => {
|
||||
const busy = busyRequestId === request.requestId;
|
||||
return (
|
||||
<div
|
||||
key={request.requestId}
|
||||
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<AccountAvatar account={request} sizeClass="size-9" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{request.email}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{request.name ? `${request.name} · ` : ""}
|
||||
requested {request.requestedAtLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConfirmLink(request)}
|
||||
disabled={busyRequestId !== null}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
Create Account Link
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busyRequestId !== null}
|
||||
onClick={() => setDenyIntent({ request, block: false })}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Deny Account Link
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={busyRequestId !== null}
|
||||
onClick={() => setDenyIntent({ request, block: true })}
|
||||
>
|
||||
<ShieldBan className="size-3.5" />
|
||||
Deny and Block Account Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={denyIntent !== null}
|
||||
onOpenChange={(open) => !open && setDenyIntent(null)}
|
||||
title={denyIntent?.block ? "Deny and block this account?" : "Deny this request?"}
|
||||
description={
|
||||
denyIntent?.block
|
||||
? `Deny the link request from ${denyIntent?.request?.email} and block that account from sending any more link requests to this one. You can lift the block later from the Blocked Account Link Requests section.`
|
||||
: `Deny the link request from ${denyIntent?.request?.email}. They can request again later unless you block them.`
|
||||
}
|
||||
confirmLabel={denyIntent?.block ? "Deny and Block" : "Deny"}
|
||||
onConfirm={handleDeny}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Clock, Link2, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { requestAccountLink } from "@/lib/actions/account-links";
|
||||
|
||||
/**
|
||||
* "Request Account Link": asks to link this account with another one on
|
||||
* the server by email. On success the form is replaced by the
|
||||
* "awaiting confirmation" status, since the recipient still has to accept
|
||||
* it from their own Profile page.
|
||||
*/
|
||||
export function RequestLinkForm() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// Set once a request goes out; the section then shows the awaiting
|
||||
// confirmation status until the recipient responds (or it's denied).
|
||||
const [requestedEmail, setRequestedEmail] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await requestAccountLink(email);
|
||||
if (result?.error) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
setRequestedEmail(email.trim().toLowerCase());
|
||||
toast.success("Link request sent.");
|
||||
} catch {
|
||||
toast.error("Couldn't send the link request. Try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Request Account Link</CardTitle>
|
||||
<CardDescription>
|
||||
Link this account with another account on this server so you can
|
||||
toggle between them. The other account has to accept the request.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{requestedEmail ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>
|
||||
Request sent to{" "}
|
||||
<span className="font-medium">{requestedEmail}</span> — awaiting
|
||||
confirmation.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor="link-request-email">Account email</Label>
|
||||
<Input
|
||||
id="link-request-email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting || !email.trim()}>
|
||||
{submitting ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="size-3.5" />
|
||||
)}
|
||||
{submitting ? "Requesting…" : "Request Account Link"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type ThemeName,
|
||||
type RawTheme,
|
||||
} from "@/lib/themes";
|
||||
import { saveUserTheme } from "@/lib/actions/profile";
|
||||
|
||||
// Re-exported so existing imports keep working.
|
||||
export { THEMES, DARK_SURFACES };
|
||||
|
|
@ -51,6 +52,14 @@ export type { ThemeName, RawTheme };
|
|||
* The provider also picks up a `data-project-theme` marker on <html> at
|
||||
* mount -- set by the project page's no-FOUC inline script -- so a scoped
|
||||
* theme is in effect from the very first frame of a hard page load.
|
||||
*
|
||||
* Per-user themes (per-account, survive account toggles):
|
||||
* The root layout passes `userTheme` -- the signed-in user's preference
|
||||
* from the profile row (saveUserTheme action) -- instead of defaulting
|
||||
* to localStorage. That stored value is the initial state and the
|
||||
* no-FOUC script applies it before first paint, and setTheme() persists
|
||||
* changes back to the profile. Anonymous visitors keep the classic
|
||||
* localStorage-only behavior.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
|
|
@ -121,15 +130,27 @@ function readInitialScope(): ThemeName | null {
|
|||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
userTheme,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
defaultTheme?: RawTheme;
|
||||
/**
|
||||
* The signed-in user's stored theme preference -- the root layout reads
|
||||
* it from the profile row (and bakes it into the no-FOUC script). When
|
||||
* defined it is the source of truth: the provider starts from it instead
|
||||
* of localStorage, and every setTheme() call is persisted to the profile
|
||||
* (saveUserTheme) so each linked account keeps its own look after an
|
||||
* account toggle. When undefined (anonymous visitor) the provider keeps
|
||||
* the old localStorage-only behavior.
|
||||
*/
|
||||
userTheme?: RawTheme;
|
||||
}) {
|
||||
// Both the server and the first client render agree on `defaultTheme`
|
||||
// (hydration-safe); the stored preference is picked up right after mount.
|
||||
// The inline script in app/layout.tsx already applied the right classes
|
||||
// before first paint, so there is no visible jump either way.
|
||||
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
|
||||
// Both the server and the first client render agree on the initial theme
|
||||
// (hydration-safe); anonymous users' localStorage preference is picked up
|
||||
// right after mount. The inline script in app/layout.tsx already applied
|
||||
// the right classes before first paint, so there is no visible jump
|
||||
// either way.
|
||||
const [theme, setThemeState] = useState<RawTheme>(userTheme ?? defaultTheme);
|
||||
// Starts null (matching the server render); the project-page marker is
|
||||
// applied in a layout effect below, before paint.
|
||||
const [scope, setScope] = useState<ThemeName | null>(null);
|
||||
|
|
@ -137,13 +158,19 @@ export function ThemeProvider({
|
|||
const resolvedTheme: ThemeName = scope ?? resolve(theme);
|
||||
|
||||
useEffect(() => {
|
||||
// Signed-in users: their profile's stored theme (userTheme) is
|
||||
// authoritative -- it is already the initial state, and it's what the
|
||||
// no-FOUC script applied, so localStorage is deliberately not
|
||||
// consulted (a stale entry from another account must not win).
|
||||
if (userTheme !== undefined) return;
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from localStorage (external source), same pattern as the project-theme marker effect below
|
||||
if (stored) setThemeState(stored as RawTheme);
|
||||
} catch {
|
||||
/* storage unavailable -- stay on defaultTheme */
|
||||
}
|
||||
}, []);
|
||||
}, [userTheme]);
|
||||
|
||||
// Pick up a project's no-FOUC marker (hard page load). Runs in the same
|
||||
// pre-paint window as the apply effect below, so the global-theme flash
|
||||
|
|
@ -182,14 +209,27 @@ export function ThemeProvider({
|
|||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [defaultTheme]);
|
||||
|
||||
const setTheme = useCallback((t: RawTheme) => {
|
||||
const setTheme = useCallback(
|
||||
(t: RawTheme) => {
|
||||
setThemeState(t);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
} catch {
|
||||
/* ignore -- preference just won't persist */
|
||||
}
|
||||
}, []);
|
||||
// Signed-in: persist to the profile row so the next load -- a hard
|
||||
// navigation, an account toggle, another device -- applies this
|
||||
// account's theme. Fire-and-forget: the UI already reflects the
|
||||
// change; a failed write just means the next load falls back to the
|
||||
// previously stored value.
|
||||
if (userTheme !== undefined) {
|
||||
void saveUserTheme(t).catch(() => {
|
||||
/* best effort -- in-memory + localStorage state still stands */
|
||||
});
|
||||
}
|
||||
},
|
||||
[userTheme]
|
||||
);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AuthError } from "next-auth";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUserId } from "@/lib/auth-helpers";
|
||||
import { EmailSchema } from "@/lib/validation/auth";
|
||||
import { Prisma } from "@/lib/generated/prisma/client";
|
||||
import { Role } from "@/lib/generated/prisma/enums";
|
||||
import { signIn, signOut } from "@/auth";
|
||||
|
||||
/** Shared shape for the link-management actions: `error` on failure, a
|
||||
* plain `{}` (or `ok`) on success. Kept as one flat object -- a discriminated
|
||||
* union with an index signature confuses React types on the client side. */
|
||||
export type AccountLinkActionResult = { error?: string; ok?: true };
|
||||
|
||||
/** Re-render the Profile page after any change to the linking state.
|
||||
* (The (app) layout doesn't show link state, so /profile alone suffices.) */
|
||||
function revalidateProfile() {
|
||||
revalidatePath("/profile");
|
||||
}
|
||||
|
||||
/** The canonical "does a confirmed link already exist between these two
|
||||
* accounts?" check -- order-independent, since a link is stored once per
|
||||
* pair in either direction. */
|
||||
function linkedPairWhere(userId: string, otherUserId: string) {
|
||||
return {
|
||||
OR: [
|
||||
{ userId, linkedUserId: otherUserId },
|
||||
{ userId: otherUserId, linkedUserId: userId },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends (or silently refreshes) a link request to another account. The
|
||||
* recipient sees it in their Profile page's "Requested Account Link"
|
||||
* section and can create, deny, or deny-and-block it. Returns
|
||||
* `{ error }` on failure; the UI then shows "awaiting confirmation".
|
||||
*/
|
||||
export async function requestAccountLink(email: string): Promise<AccountLinkActionResult> {
|
||||
const userId = await requireUserId();
|
||||
const parsed = EmailSchema.safeParse(email);
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? "Enter a valid email address." };
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { email: parsed.data } });
|
||||
if (!target) {
|
||||
return { error: "No account with that email address on this server." };
|
||||
}
|
||||
if (target.id === userId) {
|
||||
return { error: "That's the account you're signed in as." };
|
||||
}
|
||||
|
||||
const existingLink = await prisma.accountLink.findFirst({
|
||||
where: linkedPairWhere(userId, target.id),
|
||||
});
|
||||
if (existingLink) {
|
||||
return { error: "Those accounts are already linked." };
|
||||
}
|
||||
|
||||
// "Deny and Block Account Link" on their side stops new requests from
|
||||
// this account to theirs.
|
||||
const blocked = await prisma.accountLinkBlock.findUnique({
|
||||
where: { fromUserId_toUserId: { fromUserId: userId, toUserId: target.id } },
|
||||
});
|
||||
if (blocked) {
|
||||
return { error: "That account has blocked link requests from you." };
|
||||
}
|
||||
|
||||
// Upsert, not create: the composite unique makes re-requesting while a
|
||||
// pending request already exists a no-op refresh rather than a 500.
|
||||
await prisma.accountLinkRequest.upsert({
|
||||
where: { fromUserId_toUserId: { fromUserId: userId, toUserId: target.id } },
|
||||
create: { fromUserId: userId, toUserId: target.id },
|
||||
update: {},
|
||||
});
|
||||
|
||||
revalidateProfile();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient's "Create Account Link": confirms a pending request and
|
||||
* creates the (single, order-independent) link between the two accounts.
|
||||
* Also closes any remaining pending requests between the pair, in either
|
||||
* direction -- once linked, neither side needs to act on them anymore.
|
||||
*/
|
||||
export async function createAccountLink(requestId: string): Promise<AccountLinkActionResult> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const request = await prisma.accountLinkRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
});
|
||||
// Only the *recipient* can confirm; the requester has no buttons here.
|
||||
if (!request || request.toUserId !== userId) {
|
||||
return { error: "That link request no longer exists." };
|
||||
}
|
||||
|
||||
const requesterId = request.fromUserId;
|
||||
if (requesterId === userId) {
|
||||
return { error: "That link request no longer exists." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.accountLink.create({
|
||||
// Canonical order: the smaller id first. Makes the pair unique
|
||||
// regardless of which direction the confirming request came in.
|
||||
data: {
|
||||
userId: [userId, requesterId].sort()[0],
|
||||
linkedUserId: [userId, requesterId].sort()[1],
|
||||
},
|
||||
}),
|
||||
prisma.accountLinkRequest.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromUserId: userId, toUserId: requesterId },
|
||||
{ fromUserId: requesterId, toUserId: userId },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
// A concurrent confirm (both sides clicked at once) can win the
|
||||
// unique pair -- treat as success: the link exists, which is the
|
||||
// outcome both sides wanted.
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidateProfile();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient's "Deny Account Link": drops the pending request. The
|
||||
* requester may try again later (there's no block).
|
||||
*/
|
||||
export async function denyAccountLink(requestId: string): Promise<AccountLinkActionResult> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId } });
|
||||
if (!request || request.toUserId !== userId) {
|
||||
return { error: "That link request no longer exists." };
|
||||
}
|
||||
|
||||
await prisma.accountLinkRequest.delete({ where: { id: requestId } });
|
||||
|
||||
revalidateProfile();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient's "Deny and Block Account Link": denies the request and
|
||||
* blocks any future link requests from that account to this one. The
|
||||
* block shows up in the "Blocked Account Link Requests" section, where it
|
||||
* can be lifted again.
|
||||
*/
|
||||
export async function denyAndBlockAccountLink(requestId: string): Promise<AccountLinkActionResult> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId } });
|
||||
if (!request || request.toUserId !== userId) {
|
||||
return { error: "That link request no longer exists." };
|
||||
}
|
||||
|
||||
const requesterId = request.fromUserId;
|
||||
await prisma.$transaction([
|
||||
prisma.accountLinkRequest.delete({ where: { id: requestId } }),
|
||||
// Upsert: a block may already exist from an earlier denial -- keep it,
|
||||
// don't fail on it.
|
||||
prisma.accountLinkBlock.upsert({
|
||||
where: { fromUserId_toUserId: { fromUserId: requesterId, toUserId: userId } },
|
||||
create: { fromUserId: requesterId, toUserId: userId },
|
||||
update: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
revalidateProfile();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Remove Link" from the Linked Accounts list: either account in the pair
|
||||
* can remove the link. Removes only the link itself -- pending requests
|
||||
* and blocks between the two accounts are left as-is (a re-request simply
|
||||
* restarts the flow).
|
||||
*/
|
||||
export async function removeAccountLink(linkId: string): Promise<AccountLinkActionResult> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const link = await prisma.accountLink.findUnique({ where: { id: linkId } });
|
||||
if (!link || (link.userId !== userId && link.linkedUserId !== userId)) {
|
||||
return { error: "That account link no longer exists." };
|
||||
}
|
||||
|
||||
await prisma.accountLink.delete({ where: { id: linkId } });
|
||||
|
||||
revalidateProfile();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts a block the signed-in account issued ("Allow requests" in the
|
||||
* Blocked section), letting that account send link requests again.
|
||||
*/
|
||||
export async function unblockAccountLinkRequests(blockId: string): Promise<AccountLinkActionResult> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const block = await prisma.accountLinkBlock.findUnique({ where: { id: blockId } });
|
||||
if (!block || block.toUserId !== userId) {
|
||||
return { error: "That block no longer exists." };
|
||||
}
|
||||
|
||||
await prisma.accountLinkBlock.delete({ where: { id: blockId } });
|
||||
|
||||
revalidateProfile();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Toggle" button: verifies the signed-in user is actually linked to
|
||||
* the given account, mints a one-time, short-lived token for the
|
||||
* "account-switch" provider (auth.ts), signs the current session out, and
|
||||
* signs in as the linked account -- all server-side, so the linked
|
||||
* account's password is never needed. The client then navigates to the
|
||||
* returned URL with the new session cookie.
|
||||
*
|
||||
* The token rides nowhere the user can craft it: 256 bits of randomness,
|
||||
* single-use (consumed in the provider's authorize()), and it expires
|
||||
* within a minute, so a copy in logs or history is worthless.
|
||||
*/
|
||||
const SWITCH_TOKEN_TTL_MS = 60_000;
|
||||
|
||||
export type ToggleResult = { url?: string; error?: string };
|
||||
|
||||
export async function toggleAccount(linkedUserId: string): Promise<ToggleResult> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
if (linkedUserId === userId) {
|
||||
return { error: "That's the account you're already signed in as." };
|
||||
}
|
||||
|
||||
const link = await prisma.accountLink.findFirst({
|
||||
where: linkedPairWhere(userId, linkedUserId),
|
||||
});
|
||||
if (!link) {
|
||||
return { error: "Those accounts aren't linked." };
|
||||
}
|
||||
|
||||
// Check before signing the user out: a PENDING (or deleted) target can
|
||||
// never hold a session (the account-switch provider rejects it), so
|
||||
// failing here keeps the user signed in to their own account instead of
|
||||
// stranding them logged out.
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: linkedUserId },
|
||||
select: { role: true },
|
||||
});
|
||||
if (!target || target.role === Role.PENDING) {
|
||||
return { error: "That account can't sign in right now -- it may be pending approval." };
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
await prisma.accountSwitchToken.create({
|
||||
data: {
|
||||
token,
|
||||
requestedBy: userId,
|
||||
targetUserId: linkedUserId,
|
||||
expiresAt: new Date(Date.now() + SWITCH_TOKEN_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
// Drop the current session first, then take the linked account's. Both
|
||||
// responses set the same session cookie, so the second write wins and the
|
||||
// browser is left exactly one signed-in account -- the toggled-to one.
|
||||
await signOut({ redirect: false });
|
||||
|
||||
try {
|
||||
// redirect:false: signIn() commits the new session cookie through
|
||||
// next/headers and returns the redirect URL as a string (see
|
||||
// next-auth/lib/actions.js) instead of throwing a framework redirect --
|
||||
// the client does the navigation so it can toast on failure.
|
||||
const url = await signIn("account-switch", { token, redirectTo: "/", redirect: false });
|
||||
return { url };
|
||||
} catch (error) {
|
||||
// authorize() throws SwitchAccountSignin (an AuthError) for a
|
||||
// missing/expired/consumed token or a PENDING target account.
|
||||
if (error instanceof AuthError) {
|
||||
return { error: "Couldn't switch to that account. Check that it's still linked and active." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import sharp from "sharp";
|
|||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUserId } from "@/lib/auth-helpers";
|
||||
import { ProfileNameSchema } from "@/lib/validation/profile";
|
||||
import { ProfileNameSchema, ThemeSchema } from "@/lib/validation/profile";
|
||||
|
||||
/**
|
||||
* Refresh both the Profile page (its form's initial values) and the (app)
|
||||
|
|
@ -101,3 +101,20 @@ export async function removeAvatar(): Promise<ProfileResult> {
|
|||
revalidateProfile();
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the user's global theme preference -- the theme menu's setTheme
|
||||
* (components/theme/theme-provider.tsx) calls this for signed-in users.
|
||||
* Stored on the profile row so the preference belongs to the *account*,
|
||||
* not the browser: each account in a linked set keeps its own look after
|
||||
* an account toggle, and the root layout (app/layout.tsx) applies it from
|
||||
* the very first frame of the next page load. No revalidation needed: the
|
||||
* value is read on navigation, which re-renders the layout.
|
||||
*/
|
||||
export async function saveUserTheme(theme: string): Promise<ProfileResult> {
|
||||
const userId = await requireUserId();
|
||||
const parsed = ThemeSchema.parse(theme);
|
||||
|
||||
await prisma.user.update({ where: { id: userId }, data: { theme: parsed } });
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,18 @@ import { CredentialsSignin } from "next-auth";
|
|||
export class PendingAccountSignin extends CredentialsSignin {
|
||||
code = "pending-account";
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown from the "account-switch" provider's authorize() (in auth.ts)
|
||||
* when the one-time switch token is missing, already used, expired, or
|
||||
* the target account can't log in (e.g. PENDING). signIn() in the
|
||||
* toggleAccount action (lib/actions/account-links.ts) then fails with it
|
||||
* and the Profile page shows an error toast, keeping the user on the
|
||||
* login flow instead of silently switching accounts.
|
||||
*
|
||||
* (Same mechanism as PendingAccountSignin: Auth.js rethrows errors from
|
||||
* authorize() as-is when signIn() is called from a route/Server Action.)
|
||||
*/
|
||||
export class SwitchAccountSignin extends CredentialsSignin {
|
||||
code = "switch-account";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,43 @@ export function themeColorScheme(name: ThemeName): "light" | "dark" {
|
|||
return DARK_SURFACES.includes(name) ? "dark" : "light";
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of the root layout's no-FOUC theme script (app/layout.tsx renders
|
||||
* it as plain server HTML so it runs before first paint).
|
||||
*
|
||||
* `serverTheme` is the signed-in user's stored preference (the layout
|
||||
* reads it from the profile row). When present it is applied verbatim and
|
||||
* localStorage is *not* consulted -- that is what lets each account in a
|
||||
* linked set keep its own look in the same browser after an account
|
||||
* toggle. When absent (anonymous visitor) the script keeps the old
|
||||
* behavior: restore the browser's localStorage preference, falling back
|
||||
* to the OS light/dark setting.
|
||||
*/
|
||||
export function themeInitScript(serverTheme?: RawTheme | null): string {
|
||||
const allClasses = JSON.stringify([...Object.values(THEME_CLASSES), "light"]);
|
||||
const classMap = JSON.stringify(THEME_CLASSES);
|
||||
const dark = JSON.stringify(DARK_SURFACES);
|
||||
const system =
|
||||
"(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'default'";
|
||||
const apply =
|
||||
`r.classList.add(m[v]);r.style.colorScheme=d.indexOf(v)>=0?'dark':'light';`;
|
||||
if (serverTheme != null) {
|
||||
return (
|
||||
`try{var r=document.documentElement,c=${allClasses};` +
|
||||
`for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}` +
|
||||
`var v=${JSON.stringify(serverTheme)};if(v==='system'){v=${system};}` +
|
||||
`var m=${classMap},d=${dark};if(m[v]){${apply}}catch(e){}`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`try{var r=document.documentElement,c=${allClasses};` +
|
||||
`for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}` +
|
||||
`var v=null;try{v=localStorage.getItem('theme');}catch(e){}` +
|
||||
`if(v==='system'||!v){v=${system};}` +
|
||||
`var m=${classMap},d=${dark};if(m[v]){${apply}}catch(e){}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline-script source that switches <html> over to `theme` immediately
|
||||
* (before first paint), the same way the global no-FOUC script in
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { THEMES } from "@/lib/themes";
|
||||
|
||||
const NamePartSchema = z.string().trim().max(60, "Must be 60 characters or fewer");
|
||||
|
||||
export const ProfileNameSchema = z.object({
|
||||
|
|
@ -7,3 +9,11 @@ export const ProfileNameSchema = z.object({
|
|||
lastName: NamePartSchema,
|
||||
});
|
||||
export type ProfileName = z.infer<typeof ProfileNameSchema>;
|
||||
|
||||
// The global theme preference stored on the profile row (User.theme):
|
||||
// one of the real themes, or "system" = follow the OS light/dark setting.
|
||||
// Written only by saveUserTheme (lib/actions/profile.ts), read by the
|
||||
// root layout (app/layout.tsx) and the theme provider -- the cast in the
|
||||
// latter two places is safe because of this schema.
|
||||
export const ThemeSchema = z.enum([...THEMES, "system"]);
|
||||
export type ThemePreference = z.infer<typeof ThemeSchema>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "AccountLinkRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fromUserId" TEXT NOT NULL,
|
||||
"toUserId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AccountLinkRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"linkedUserId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AccountLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- A link is between two *different* accounts; the app layer also refuses
|
||||
-- self-links before ever reaching this constraint.
|
||||
ALTER TABLE "AccountLink" ADD CONSTRAINT "account_link_not_self" CHECK ("userId" <> "linkedUserId");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountLinkBlock" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fromUserId" TEXT NOT NULL,
|
||||
"toUserId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AccountLinkBlock_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountSwitchToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"requestedBy" TEXT NOT NULL,
|
||||
"targetUserId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AccountSwitchToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountLinkRequest_fromUserId_toUserId_unique" ON "AccountLinkRequest"("fromUserId", "toUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountLinkRequest_toUserId_idx" ON "AccountLinkRequest"("toUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountLink_userId_linkedUserId_unique" ON "AccountLink"("userId", "linkedUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountLinkBlock_fromUserId_toUserId_unique" ON "AccountLinkBlock"("fromUserId", "toUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountSwitchToken_token_key" ON "AccountSwitchToken"("token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountLinkRequest" ADD CONSTRAINT "AccountLinkRequest_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountLinkRequest" ADD CONSTRAINT "AccountLinkRequest_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountLink" ADD CONSTRAINT "AccountLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountLink" ADD CONSTRAINT "AccountLink_linkedUserId_fkey" FOREIGN KEY ("linkedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountLinkBlock" ADD CONSTRAINT "AccountLinkBlock_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountLinkBlock" ADD CONSTRAINT "AccountLinkBlock_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountSwitchToken" ADD CONSTRAINT "AccountSwitchToken_requestedBy_fkey" FOREIGN KEY ("requestedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountSwitchToken" ADD CONSTRAINT "AccountSwitchToken_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "theme" TEXT;
|
||||
|
|
@ -46,6 +46,13 @@ model User {
|
|||
// Always server-generated from the user's upload (resized to 256x256 via
|
||||
// sharp in lib/actions/profile.ts), never stored as the raw upload.
|
||||
avatar String?
|
||||
// Global theme preference: one of lib/themes' THEMES, or "system" (follow
|
||||
// the OS light/dark setting). Null = never set -- the root layout treats
|
||||
// that as "system". Stored per account (not just in localStorage) so each
|
||||
// account in a linked set keeps its own look when the browser toggles
|
||||
// between them; the theme menu persists it via saveUserTheme
|
||||
// (lib/actions/profile.ts) and app/layout.tsx applies it on every load.
|
||||
theme String?
|
||||
// The very first person to sign up becomes ADMIN regardless of
|
||||
// signupMode (the site needs at least one admin to bootstrap). Everyone
|
||||
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
|
||||
|
|
@ -57,6 +64,104 @@ model User {
|
|||
categories Category[]
|
||||
projects Project[] @relation("ProjectOwner")
|
||||
scheduledTodos ScheduledTodo[]
|
||||
|
||||
// Account linking (see lib/actions/account-links.ts): pending link
|
||||
// requests this account has sent or received, confirmed links it is part
|
||||
// of, request blocks it has issued, and switch tokens it has requested.
|
||||
linkRequestsFrom AccountLinkRequest[] @relation("LinkRequestFrom")
|
||||
linkRequestsTo AccountLinkRequest[] @relation("LinkRequestTo")
|
||||
accountLinksUser AccountLink[] @relation("AccountLinkUser")
|
||||
accountLinksLinked AccountLink[] @relation("AccountLinkLinkedUser")
|
||||
linkBlocksFrom AccountLinkBlock[] @relation("LinkBlockFrom")
|
||||
linkBlocksTo AccountLinkBlock[] @relation("LinkBlockTo")
|
||||
switchTokenRequests AccountSwitchToken[] @relation("SwitchTokenRequester")
|
||||
switchTokenTargets AccountSwitchToken[] @relation("SwitchTokenTarget")
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending account-link request: `fromUser` asked to link with `toUser`,
|
||||
* who must decide ("Create Account Link", "Deny Account Link", or "Deny
|
||||
* and Block Account Link" on the Profile page). At most one pending
|
||||
* request per direction per pair (the composite unique), so re-requesting
|
||||
* is a no-op upsert rather than a duplicate. Denied requests are deleted,
|
||||
* not archived -- a later request simply starts fresh.
|
||||
*/
|
||||
model AccountLinkRequest {
|
||||
id String @id @default(cuid())
|
||||
fromUserId String
|
||||
toUserId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
fromUser User @relation("LinkRequestFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
|
||||
toUser User @relation("LinkRequestTo", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([fromUserId, toUserId])
|
||||
@@index([toUserId])
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmed account link, stored exactly once per pair (either order) --
|
||||
* lib/actions/account-links.ts normalizes the pair into the same order
|
||||
* before creating, and the composite unique below makes a concurrent
|
||||
* double-confirm fail with a unique violation instead of a duplicate row.
|
||||
* Either linked account can remove the link; removing it does not prevent
|
||||
* re-requesting (the request flow starts over).
|
||||
* `userId` and `linkedUserId` must differ -- enforced by the
|
||||
* account_link_not_self check constraint in the migration (Prisma has no
|
||||
* schema-level CHECK syntax).
|
||||
*/
|
||||
model AccountLink {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
linkedUserId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation("AccountLinkUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
linkedUser User @relation("AccountLinkLinkedUser", fields: [linkedUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, linkedUserId])
|
||||
}
|
||||
|
||||
/**
|
||||
* A denied-and-blocked pair: `fromUser` may no longer send account-link
|
||||
* requests to `toUser` (checked in requestAccountLink). Created by the
|
||||
* "Deny and Block Account Link" action; reversible by the blocked-against
|
||||
* account (unblockAccountLinkRequests), since a block is permanent for the
|
||||
* requester otherwise.
|
||||
*/
|
||||
model AccountLinkBlock {
|
||||
id String @id @default(cuid())
|
||||
fromUserId String
|
||||
toUserId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
fromUser User @relation("LinkBlockFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
|
||||
toUser User @relation("LinkBlockTo", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([fromUserId, toUserId])
|
||||
}
|
||||
|
||||
/**
|
||||
* A short-lived, single-use token letting one linked account switch the
|
||||
* browser's session to the other without knowing its password (the
|
||||
* Profile page "Toggle" button). Created by toggleAccount (lib/actions/
|
||||
* account-links.ts) after the link is verified, consumed exactly once by
|
||||
* authorize() in the "account-switch" provider of auth.ts.
|
||||
*/
|
||||
model AccountSwitchToken {
|
||||
id String @id @default(cuid())
|
||||
token String @unique
|
||||
requestedBy String
|
||||
targetUserId String
|
||||
// Absolute expiry -- the token is dead after this even if unused.
|
||||
expiresAt DateTime
|
||||
// Set the moment authorize() accepts it. A token with usedAt set is
|
||||
// never valid again, so a replayed URL can't switch twice.
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
requester User @relation("SwitchTokenRequester", fields: [requestedBy], references: [id], onDelete: Cascade)
|
||||
target User @relation("SwitchTokenTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,3 +5,30 @@ export interface ProfileDTO {
|
|||
lastName: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/** A shared identity display: name when the account set one (profile or
|
||||
* sign-up name), otherwise null and the email stands on its own. */
|
||||
export interface LinkAccountIdentity {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/** A pending link request addressed to the signed-in account (they asked,
|
||||
* we decide). */
|
||||
export interface PendingLinkRequestDTO extends LinkAccountIdentity {
|
||||
requestId: string;
|
||||
requestedAtLabel: string;
|
||||
}
|
||||
|
||||
/** A confirmed link: the other account in the pair. */
|
||||
export interface LinkedAccountDTO extends LinkAccountIdentity {
|
||||
linkId: string;
|
||||
}
|
||||
|
||||
/** An account the signed-in account blocked from sending link requests. */
|
||||
export interface BlockedRequesterDTO extends LinkAccountIdentity {
|
||||
blockId: string;
|
||||
blockedAtLabel: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue