Organize/components/profile/linked-accounts.tsx

133 lines
4.6 KiB
TypeScript

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