feat(web): settings in three pages that save as you go

Six settings pages become Account, Preferences and AI & developer, with an
in-page nav beside a 680px column (tabs on tablets, a three-row root on
phones, which the Account tab opens).

- Account: photo upload, name, username with the instance host, and email,
  each saving on blur; password, a two-step verification switch, passkeys and
  connected sign-in; Export everything as one zip of documents and
  applications; Delete account with the counts of what goes and typing
  "delete"; Sign out.
- Preferences: Light, Dark and System tiles, the interface language and the
  motion note.
- AI & developer: provider rows with Test ("Connected · 420 ms" or the exact
  error) and an Edit dialog holding the switch and delete; Add provider with
  all sixteen; API keys in a table with a New key dialog (30 days, 90 days,
  Never), the key shown once, and Revoke with Undo; the MCP address with Copy.

The old settings addresses redirect, and links across the app, the command
palette and the user menu point at the new pages.
This commit is contained in:
Amruth Pillai
2026-09-29 00:33:37 +02:00
parent 55db11aea8
commit 9f809890e4
61 changed files with 2237 additions and 2475 deletions
+1
View File
@@ -74,6 +74,7 @@
"better-auth": "1.7.6",
"cmdk": "^1.1.1",
"es-toolkit": "^1.52.0",
"fflate": "^0.8.3",
"fuse.js": "^7.5.0",
"immer": "^11.1.18",
"js-cookie": "^3.0.8",
-243
View File
@@ -1,243 +0,0 @@
import type { DialogProps } from "../store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CopyIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useCopyToClipboard } from "usehooks-ts";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@reactive-resume/ui/components/input-group";
import { toast } from "@reactive-resume/ui/components/toast";
import { Combobox } from "@/components/ui/combobox";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { useAppForm } from "@/libs/tanstack-form";
import { useDialogStore } from "../store";
const formSchema = z.object({
name: z.string().min(1).max(64),
expiresIn: z.number().min(1),
});
export function CreateApiKeyDialog(_: DialogProps<"api-key.create">) {
const [apiKey, setApiKey] = useState<string | null>(null);
if (apiKey) return <CopyApiKeyForm apiKey={apiKey} />;
return <CreateApiKeyForm setApiKey={setApiKey} />;
}
type CreateApiKeyFormProps = {
setApiKey: (apiKey: string) => void;
};
const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
const form = useAppForm({
defaultValues: {
name: "",
expiresIn: 3600 * 24 * 30,
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
const toastId = toast.add({ type: "loading", description: t`Creating your API key...` });
const { data, error } = await authClient.apiKey.create({
name: value.name,
expiresIn: value.expiresIn,
});
if (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when creating an API key fails",
message: "Failed to create API key. Please try again.",
}),
),
id: toastId,
});
return;
}
setApiKey(data.key);
toast.close(toastId);
},
});
useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new API key</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This creates a new API key, which lets other programs read and change your resume data through the Reactive
Resume API.
</Trans>
</DialogDescription>
</DialogHeader>
<form
className="space-y-6 py-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="name">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
min={1}
max={64}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
<Trans>Names help you tell keys apart, so name it after what you will use it for.</Trans>
</FormDescription>
</FormItem>
)}
</form.Field>
<form.Field name="expiresIn">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Expires in</Trans>
</FormLabel>
<FormControl
render={
<Combobox
value={field.state.value}
onValueChange={(value) => value && field.handleChange(Number(value))}
options={[
{
// 1 month = 30 days
value: 3600 * 24 * 30,
label: t`1 month`,
},
{
// 3 months = 90 days
value: 3600 * 24 * 90,
label: t`3 months`,
},
{
// 6 months = 180 days
value: 3600 * 24 * 180,
label: t`6 months`,
},
{
// 1 year = 365 days
value: 3600 * 24 * 365,
label: t`1 year`,
},
]}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<DialogFooter>
<Button type="submit" disabled={isSubmitting}>
<Trans comment="Create API key dialog submit action">Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
};
type CopyApiKeyFormProps = {
apiKey: string;
};
const CopyApiKeyForm = ({ apiKey }: CopyApiKeyFormProps) => {
const queryClient = useQueryClient();
const [_, copyToClipboard] = useCopyToClipboard();
const closeDialog = useDialogStore((state) => state.closeDialog);
const onCopy = async () => {
await copyToClipboard(apiKey);
toast.add({ type: "success", description: t`Your API key has been copied to the clipboard.` });
};
const onConfirm = () => {
closeDialog();
void queryClient.invalidateQueries({ queryKey: ["auth", "api-keys"] });
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<CopyIcon />
<Trans>Here's your new API key</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Copy this secret key and use it in your applications to access your data.</Trans>
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
<InputGroup>
<InputGroupInput value={apiKey} readOnly />
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-sm" onClick={onCopy}>
<CopyIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<span className="font-medium text-muted-foreground text-sm">
<Trans>This key is shown only once. Copy it before you close this dialog.</Trans>
</span>
</div>
<DialogFooter>
<Button onClick={onConfirm}>
<Trans comment="Create API key dialog acknowledgment action after copying">Confirm</Trans>
</Button>
</DialogFooter>
</DialogContent>
);
};
@@ -1,6 +0,0 @@
import type { AnyDialogRendererEntry } from "../schemas";
import { CreateApiKeyDialog } from "./create";
export const apiKeyDialogRenderers: readonly AnyDialogRendererEntry[] = [
{ type: "api-key.create", render: () => <CreateApiKeyDialog /> },
];
+1 -2
View File
@@ -1,11 +1,10 @@
import type { DialogSchema } from "./schemas";
import { apiKeyDialogRenderers } from "./api-key/registry";
import { authDialogRenderers } from "./auth/registry";
import { documentDialogRenderers } from "./document/registry";
import { resumeDialogRenderers } from "./resume/registry";
const dialogRendererByType = new Map(
[...authDialogRenderers, ...apiKeyDialogRenderers, ...documentDialogRenderers, ...resumeDialogRenderers].map(
[...authDialogRenderers, ...documentDialogRenderers, ...resumeDialogRenderers].map(
(renderer) => [renderer.type, renderer] as const,
),
);
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NewDocumentDialogData } from "@/features/documents/new-document-di
type EmptyDialog<T extends string> = { [K in T]: { type: K; data?: undefined } }[T];
export type DialogSchema =
| EmptyDialog<"auth.change-password" | "auth.two-factor.enable" | "auth.two-factor.disable" | "api-key.create">
| EmptyDialog<"auth.change-password" | "auth.two-factor.enable" | "auth.two-factor.disable">
| {
type: "document.new";
data?: NewDocumentDialogData | undefined;
+8 -8
View File
@@ -14,11 +14,11 @@ describe("useDialogStore", () => {
describe("openDialog", () => {
it("opens a dialog and sets activeDialog", () => {
useDialogStore.getState().openDialog("api-key.create", undefined);
useDialogStore.getState().openDialog("auth.change-password", undefined);
const state = useDialogStore.getState();
expect(state.open).toBe(true);
expect(state.activeDialog?.type).toBe("api-key.create");
expect(state.activeDialog?.type).toBe("auth.change-password");
});
it("clears any existing onBeforeClose handler", () => {
@@ -44,7 +44,7 @@ describe("useDialogStore", () => {
it("immediately sets open to false", () => {
useDialogStore.setState({
open: true,
activeDialog: { type: "api-key.create", data: undefined },
activeDialog: { type: "auth.change-password", data: undefined },
});
useDialogStore.getState().closeDialog();
@@ -54,7 +54,7 @@ describe("useDialogStore", () => {
it("clears activeDialog after 300ms (animation finished)", () => {
useDialogStore.setState({
open: true,
activeDialog: { type: "api-key.create", data: undefined },
activeDialog: { type: "auth.change-password", data: undefined },
});
useDialogStore.getState().closeDialog();
@@ -65,7 +65,7 @@ describe("useDialogStore", () => {
});
it.each([false, true])("preserves a replacement dialog and its own closing delay (closing=%s)", (closing) => {
useDialogStore.getState().openDialog("api-key.create", undefined);
useDialogStore.getState().openDialog("auth.change-password", undefined);
useDialogStore.getState().closeDialog();
vi.advanceTimersByTime(100);
@@ -99,7 +99,7 @@ describe("useDialogStore", () => {
});
it("closes immediately when no onBeforeClose handler", () => {
useDialogStore.setState({ open: true, activeDialog: { type: "api-key.create", data: undefined } });
useDialogStore.setState({ open: true, activeDialog: { type: "auth.change-password", data: undefined } });
useDialogStore.getState().onOpenChange(false);
expect(useDialogStore.getState().open).toBe(false);
@@ -109,7 +109,7 @@ describe("useDialogStore", () => {
const handler = vi.fn().mockResolvedValue(true);
useDialogStore.setState({
open: true,
activeDialog: { type: "api-key.create", data: undefined },
activeDialog: { type: "auth.change-password", data: undefined },
onBeforeClose: handler,
});
@@ -122,7 +122,7 @@ describe("useDialogStore", () => {
const cancel = vi.fn();
useDialogStore.setState({
open: true,
activeDialog: { type: "api-key.create", data: undefined },
activeDialog: { type: "auth.change-password", data: undefined },
onBeforeClose: handler,
});
@@ -148,7 +148,7 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void navigate({ to: "/dashboard/settings/integrations" })}>
<DropdownMenuItem onClick={() => void navigate({ to: "/dashboard/settings/ai" })}>
<Trans>Connect another…</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -66,7 +66,7 @@ export function ProviderSetup() {
<p className="text-ink-3 text-xs">
<Trans>
More providers and options are in{" "}
<Link to="/dashboard/settings/integrations" className={buttonVariants({ variant: "link", size: "sm" })}>
<Link to="/dashboard/settings/ai" className={buttonVariants({ variant: "link", size: "sm" })}>
Settings
</Link>
. The rest of the app works fully without AI.
@@ -149,7 +149,7 @@ function NoProviderState() {
variant="secondary"
nativeButton={false}
render={
<Link to="/dashboard/settings/integrations">
<Link to="/dashboard/settings/ai">
<Trans>Open Integrations Settings</Trans>
<ArrowRightIcon />
</Link>
@@ -70,6 +70,6 @@ describe("AuthErrorPage", () => {
renderPage("access_denied");
const link = screen.getByRole("link", { name: /back to authentication settings/i });
expect(link.getAttribute("href")).toBe("/dashboard/settings/authentication");
expect(link.getAttribute("href")).toBe("/dashboard/settings/account");
});
});
+1 -1
View File
@@ -83,7 +83,7 @@ export function AuthErrorPage({ code, description }: AuthErrorPageProps) {
</Alert>
{returnsToSettings ? (
<Link to="/dashboard/settings/authentication" className={buttonVariants({ variant: "secondary" })}>
<Link to="/dashboard/settings/account" className={buttonVariants({ variant: "secondary" })}>
<ArrowLeftIcon />
<Trans comment="Action returning a signed-in user to the page where they manage linked providers">
Back to authentication settings
@@ -5,14 +5,11 @@ import {
GearIcon,
HouseSimpleIcon,
KeyIcon,
OpenAiLogoIcon,
PlusIcon,
ReadCvLogoIcon,
SealCheckIcon,
ShieldCheckIcon,
TrashIcon,
UserCircleIcon,
UserGearIcon,
} from "@phosphor-icons/react";
import { useNavigate, useRouteContext } from "@tanstack/react-router";
import { CommandItem } from "@reactive-resume/ui/components/command";
@@ -117,16 +114,24 @@ export function NavigationCommandGroup() {
<BaseCommandGroup page="settings" heading={<Trans>Settings</Trans>}>
<CommandItem
keywords={[t`Profile`]}
value="navigation.settings.profile"
onSelect={() => onNavigate("/dashboard/settings/profile")}
keywords={[
t`Account`,
t`Profile`,
t`Password`,
t`Two-step verification`,
t`Passkeys`,
t`Export Data`,
t`Delete Account`,
]}
value="navigation.settings.account"
onSelect={() => onNavigate("/dashboard/settings/account")}
>
<UserCircleIcon />
<Trans>Profile</Trans>
<Trans>Account</Trans>
</CommandItem>
<CommandItem
keywords={[t`Preferences`]}
keywords={[t`Preferences`, t`Theme`, t`Language`]}
value="navigation.settings.preferences"
onSelect={() => onNavigate("/dashboard/settings/preferences")}
>
@@ -135,39 +140,12 @@ export function NavigationCommandGroup() {
</CommandItem>
<CommandItem
keywords={[t`Authentication`]}
value="navigation.settings.authentication"
onSelect={() => onNavigate("/dashboard/settings/authentication")}
>
<ShieldCheckIcon />
<Trans>Authentication</Trans>
</CommandItem>
<CommandItem
keywords={[t`API Keys`]}
value="navigation.settings.api-keys"
onSelect={() => onNavigate("/dashboard/settings/api-keys")}
keywords={[t`AI & developer`, t`AI providers`, t`API Keys`, t`MCP`, t`Integrations`]}
value="navigation.settings.ai"
onSelect={() => onNavigate("/dashboard/settings/ai")}
>
<KeyIcon />
<Trans>API Keys</Trans>
</CommandItem>
<CommandItem
keywords={[t`Integrations`, t`Artificial Intelligence`]}
value="navigation.settings.integrations"
onSelect={() => onNavigate("/dashboard/settings/integrations")}
>
<OpenAiLogoIcon />
<Trans>Integrations</Trans>
</CommandItem>
<CommandItem
keywords={[t`Account`, t`Export Data`, t`Delete Account`]}
value="navigation.settings.account"
onSelect={() => onNavigate("/dashboard/settings/account")}
>
<UserGearIcon />
<Trans>Account</Trans>
<Trans>AI & developer</Trans>
</CommandItem>
</BaseCommandGroup>
</>
@@ -99,7 +99,7 @@ export function WritingTab({ data, issues }: WritingTabProps) {
</Trans>
</p>
<Link
to="/dashboard/settings/integrations"
to="/dashboard/settings/ai"
className={buttonVariants({ size: "sm", variant: "secondary", className: "w-fit" })}
>
<Trans>Open AI settings</Trans>
@@ -144,7 +144,7 @@ export function WritingTab({ data, issues }: WritingTabProps) {
<Button size="sm" onClick={run}>
<Trans>Retry</Trans>
</Button>
<Link to="/dashboard/settings/integrations" className={buttonVariants({ size: "sm", variant: "secondary" })}>
<Link to="/dashboard/settings/ai" className={buttonVariants({ size: "sm", variant: "secondary" })}>
<Trans>Open AI settings</Trans>
</Link>
</div>
@@ -1,14 +1,5 @@
import type { AuthProvider } from "@reactive-resume/auth/types";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import {
FingerprintIcon,
GithubLogoIcon,
GoogleLogoIcon,
LinkedinLogoIcon,
PasswordIcon,
VaultIcon,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useCallback } from "react";
import { match } from "ts-pattern";
@@ -61,20 +52,6 @@ export function getProviderName(providerId: AuthProvider): string {
.exhaustive();
}
/**
* Get the icon component for a social provider
*/
export function getProviderIcon(providerId: AuthProvider): ReactNode {
return match(providerId)
.with("credential", () => <PasswordIcon />)
.with("passkey", () => <FingerprintIcon />)
.with("google", () => <GoogleLogoIcon />)
.with("github", () => <GithubLogoIcon />)
.with("linkedin", () => <LinkedinLogoIcon />)
.with("custom", () => <VaultIcon />)
.exhaustive();
}
/**
* Hook to fetch and manage authentication accounts
*/
@@ -110,7 +87,7 @@ export function useAuthProviderActions() {
const providerName = getProviderName(provider);
const toastId = toast.add({ type: "loading", description: t`Linking your ${providerName} account...` });
const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/settings/authentication" });
const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/settings/account" });
if (error) {
toast.add({
@@ -165,7 +142,7 @@ export function useAuthProviderActions() {
* Possible values: "credential", "google", "github", "linkedin", "custom"
*/
export function useEnabledProviders() {
const { data: enabledProviders = [] } = useQuery(orpc.auth.providers.list.queryOptions());
const { data: enabledProviders = {} } = useQuery(orpc.auth.providers.list.queryOptions());
return { enabledProviders };
}
@@ -0,0 +1,153 @@
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useId, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@reactive-resume/ui/components/alert-dialog";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
import { SettingsRow, SettingsSection } from "../section";
import { buildAccountZip } from "./export";
const CONFIRMATION = "delete";
export function DataSection() {
const [deleting, setDeleting] = useState(false);
const exportAll = useMutation({
mutationFn: async () => {
const zip = buildAccountZip(await client.auth.exportData());
const date = new Date().toISOString().slice(0, 10);
downloadWithAnchor(new Blob([zip as BlobPart], { type: "application/zip" }), `reactive-resume-${date}.zip`);
},
onError: (error) =>
toast.add({ type: "error", description: getReadableErrorMessage(error, t`Couldn't export. Try again.`) }),
});
return (
<SettingsSection title={<Trans>Your data</Trans>}>
<SettingsRow
title={<Trans>Export everything</Trans>}
description={<Trans>All documents and applications as JSON, in one zip</Trans>}
>
<Button size="sm" variant="secondary" loading={exportAll.isPending} onClick={() => exportAll.mutate()}>
<Trans>Export</Trans>
</Button>
</SettingsRow>
<SettingsRow
title={<Trans>Delete account</Trans>}
description={<Trans>Permanently removes everything. Export first if you want a copy.</Trans>}
>
<Button
size="sm"
variant="ghost"
className="border border-danger font-semibold text-danger-text hover:bg-danger-soft"
onClick={() => setDeleting(true)}
>
<Trans>Delete…</Trans>
</Button>
</SettingsRow>
<DeleteAccountDialog open={deleting} onOpenChange={setDeleting} />
</SettingsSection>
);
}
type DeleteAccountDialogProps = { open: boolean; onOpenChange: (open: boolean) => void };
/** The one confirmation in Settings that asks you to type: it names what goes, and can't be undone. */
function DeleteAccountDialog({ open, onOpenChange }: DeleteAccountDialogProps) {
const id = useId();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [typed, setTyped] = useState("");
const { data: counts } = useQuery({ ...orpc.documents.counts.queryOptions(), enabled: open });
const { data: applications } = useQuery({ ...applicationsListQueryOptions(), enabled: open });
const { data: keys } = useQuery({
queryKey: ["auth", "api-keys"],
queryFn: () => authClient.apiKey.list(),
select: ({ data }) => data?.apiKeys ?? [],
enabled: open,
});
const remove = useMutation({
mutationFn: () => client.auth.deleteAccount(),
onSuccess: async () => {
await authClient.signOut();
queryClient.clear();
toast.add({ description: t`Your account was deleted.` });
void navigate({ to: "/" });
},
onError: (error) =>
toast.add({ type: "error", description: getReadableErrorMessage(error, t`Couldn't delete the account.`) }),
});
const documents = (counts?.resume ?? 0) + (counts?.letter ?? 0) + (counts?.trash ?? 0);
const applicationCount = applications?.length ?? 0;
const keyCount = keys?.length ?? 0;
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) setTyped("");
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Delete your account?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Plural value={documents} one="# document" other="# documents" />,{" "}
<Plural value={applicationCount} one="# application" other="# applications" />,{" "}
<Plural value={keyCount} one="# API key" other="# API keys" />{" "}
<Trans>and every public link are removed permanently. This can't be undone.</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<div className="grid gap-1.5">
<label htmlFor={id} className="font-medium text-[13px]">
<Trans>
Type <b className="font-medium font-mono">{CONFIRMATION}</b> to confirm
</Trans>
</label>
<Input
id={id}
value={typed}
autoComplete="off"
className="focus-visible:border-danger focus-visible:shadow-[0_0_0_3px_var(--danger-soft)]"
onChange={(event) => setTyped(event.target.value)}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Keep account</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="danger"
disabled={typed.trim().toLowerCase() !== CONFIRMATION}
loading={remove.isPending}
onClick={() => remove.mutate()}
>
<Trans>Delete</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { strFromU8, unzipSync } from "fflate";
import { buildAccountZip } from "./export";
describe("buildAccountZip", () => {
it("puts the account, each document and the applications in their own files", () => {
const zip = buildAccountZip({
exportedAt: "2026-09-29T00:00:00.000Z",
user: { id: "u1", name: "Dana" },
resumes: [
{ id: "resume-aaaaaaaa-1", name: "Product Designer" },
{ id: "resume-bbbbbbbb-2", name: "Product Designer" },
],
coverLetters: [{ id: "letter-cccccccc", name: "Lumen letter" }],
applications: [{ id: "app-1", company: "Lumen", role: "Designer" }],
} as never);
const files = unzipSync(zip);
expect(Object.keys(files).sort()).toEqual([
"account.json",
"applications.json",
"letters/lumen-letter-letter-c.json",
"resumes/product-designer-resume-a.json",
"resumes/product-designer-resume-b.json",
]);
expect(JSON.parse(strFromU8(files["account.json"] as Uint8Array))).toEqual({
exportedAt: "2026-09-29T00:00:00.000Z",
user: { id: "u1", name: "Dana" },
});
expect(JSON.parse(strFromU8(files["applications.json"] as Uint8Array))).toEqual([
{ id: "app-1", company: "Lumen", role: "Designer" },
]);
});
});
@@ -0,0 +1,24 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { strToU8, zipSync } from "fflate";
import { slugify } from "@reactive-resume/utils/string";
type AccountExport = RouterOutput["auth"]["exportData"];
const json = (value: unknown) => strToU8(`${JSON.stringify(value, null, 2)}\n`);
// The id keeps two documents with the same name apart.
const fileName = (name: string, id: string) => `${slugify(name)}-${id.slice(0, 8)}.json`;
/**
* "Export everything": one zip with the account, each resume and letter as its own JSON file, and the applications.
* Every file is the stored data, so it can be imported again.
*/
export function buildAccountZip(data: AccountExport): Uint8Array {
const files: Record<string, Uint8Array> = {
"account.json": json({ exportedAt: data.exportedAt, user: data.user }),
"applications.json": json(data.applications),
};
for (const resume of data.resumes) files[`resumes/${fileName(resume.name, resume.id)}`] = json(resume);
for (const letter of data.coverLetters) files[`letters/${fileName(letter.name, letter.id)}`] = json(letter);
return zipSync(files);
}
@@ -0,0 +1,45 @@
import { Trans } from "@lingui/react/macro";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useRouteContext, useRouter } from "@tanstack/react-router";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { authClient } from "@/libs/auth/client";
import { DataSection } from "./data";
import { ProfileSection } from "./profile";
import { SecuritySection } from "./security";
export function AccountSettings() {
const { session } = useRouteContext({ from: "/dashboard" });
return (
<>
<ProfileSection session={session} />
<SecuritySection />
<DataSection />
<div className="border-line border-t pt-6">
<SignOutButton />
</div>
</>
);
}
export function SignOutButton() {
const router = useRouter();
const navigate = useNavigate();
const queryClient = useQueryClient();
return (
<Button
variant="secondary"
onClick={async () => {
await authClient.signOut();
queryClient.clear();
await navigate({ to: "/" });
void router.invalidate();
}}
>
<Icon name="logout" size={18} />
<Trans>Sign out</Trans>
</Button>
);
}
@@ -0,0 +1,271 @@
import type { AuthSession } from "@reactive-resume/auth/types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useRouteContext, useRouter } from "@tanstack/react-router";
import { useId, useRef, useState } from "react";
import z from "zod";
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { toast } from "@reactive-resume/ui/components/toast";
import { getInitials } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { SettingsSection } from "../section";
const nameSchema = z.string().trim().min(1).max(64);
const usernameSchema = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9._-]+$/);
const emailSchema = z.email().trim();
type FieldProps = {
label: string;
value: string;
/** Saves the new value; resolves to an error message, or null when it saved. */
save: (value: string) => Promise<string | null>;
validate: (value: string) => string | null;
prefix?: string;
hint?: React.ReactNode;
type?: "text" | "email";
autoComplete?: string;
className?: string;
};
/** A text setting that saves when it loses focus. Invalid or failed values stay, with the reason under them. */
function SavedField({
label,
value,
save,
validate,
prefix,
hint,
type = "text",
autoComplete,
className,
}: FieldProps) {
const id = useId();
const [draft, setDraft] = useState(value);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const commit = async () => {
const next = draft.trim();
if (next === value) return setError(null);
const invalid = validate(next);
if (invalid) return setError(invalid);
setSaving(true);
const failure = await save(next);
setSaving(false);
setError(failure);
};
const inputProps = {
id,
type,
value: draft,
autoComplete,
"aria-invalid": error ? true : undefined,
"aria-describedby": error ? `${id}-error` : hint ? `${id}-hint` : undefined,
"aria-busy": saving || undefined,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value),
onBlur: () => void commit(),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setDraft(value);
setError(null);
}
},
};
return (
<div className={cn("grid content-start gap-1.5", className)}>
<label htmlFor={id} className="font-medium text-ink-2 text-xs">
{label}
</label>
{prefix ? (
<InputGroup>
<InputGroupAddon>
<InputGroupText>{prefix}</InputGroupText>
</InputGroupAddon>
<InputGroupInput {...inputProps} />
</InputGroup>
) : (
<Input {...inputProps} />
)}
{error ? (
<p id={`${id}-error`} role="alert" className="text-danger-text text-xs">
{error}
</p>
) : (
hint && (
<p id={`${id}-hint`} className="text-ink-3 text-xs">
{hint}
</p>
)
)}
</div>
);
}
type ProfileSectionProps = { session: AuthSession };
export function ProfileSection({ session }: ProfileSectionProps) {
const router = useRouter();
const context = useRouteContext({ strict: false });
const smtpEnabled = context.flags?.smtpEnabled ?? false;
const user = session.user;
const host = typeof window === "undefined" ? "" : window.location.host;
const updateUser = async (patch: Parameters<typeof authClient.updateUser>[0]) => {
const { error } = await authClient.updateUser(patch);
if (error) return getReadableErrorMessage(error, t`Couldn't save. Try again.`);
await router.invalidate();
return null;
};
const changeEmail = async (email: string) => {
const { error } = await authClient.changeEmail({ newEmail: email, callbackURL: "/dashboard/settings/account" });
if (error) return getReadableErrorMessage(error, t`Couldn't change the email. Try again.`);
toast.add({
type: "success",
description: t`We sent a confirmation link to ${email}. Open it to finish the change.`,
});
return null;
};
const resendVerification = async () => {
const { error } = await authClient.sendVerificationEmail({
email: user.email,
callbackURL: "/dashboard/settings/account",
});
toast.add(
error
? { type: "error", description: getReadableErrorMessage(error, t`Couldn't send the email. Try again.`) }
: { type: "success", description: t`We sent a new verification link to ${user.email}.` },
);
};
return (
<SettingsSection title={<Trans>Profile</Trans>}>
<ProfilePhoto name={user.name} image={user.image ?? null} onChange={(image) => updateUser({ image })} />
<div className="grid gap-3 sm:grid-cols-2">
<SavedField
label={t`Name`}
value={user.name}
autoComplete="name"
validate={(value) => (nameSchema.safeParse(value).success ? null : t`Enter a name, up to 64 characters.`)}
save={(name) => updateUser({ name })}
/>
<SavedField
label={t`Username`}
value={user.username ?? ""}
prefix={`${host}/`}
autoComplete="username"
validate={(value) =>
usernameSchema.safeParse(value).success
? null
: t`Use lowercase letters, numbers, dots, hyphens and underscores.`
}
save={(username) => updateUser({ username, displayUsername: username })}
/>
<SavedField
className="sm:col-span-2"
type="email"
label={t`Email`}
value={user.email}
autoComplete="email"
validate={(value) => (emailSchema.safeParse(value).success ? null : t`Enter a valid email address.`)}
save={changeEmail}
hint={
<>
<Trans>
Changing it sends a confirmation to the new address. Your username is part of every public link.
</Trans>{" "}
{user.emailVerified ? null : smtpEnabled ? (
<button
type="button"
className="underline underline-offset-2"
onClick={() => void resendVerification()}
>
<Trans>Not verified yet. Resend the link</Trans>
</button>
) : (
<Trans>Email delivery isn't set up on this server, so the address isn't verified.</Trans>
)}
</>
}
/>
</div>
</SettingsSection>
);
}
type ProfilePhotoProps = {
name: string;
image: string | null;
onChange: (image: string | null) => Promise<string | null>;
};
/** The account photo, shown in the app. Resumes keep their own picture. */
function ProfilePhoto({ name, image, onChange }: ProfilePhotoProps) {
const input = useRef<HTMLInputElement>(null);
const upload = useMutation(orpc.storage.uploadFile.mutationOptions({ meta: { noInvalidate: true } }));
const choose = (file: File | undefined) => {
if (!file) return;
upload.mutate(file, {
onSuccess: async ({ url }) => {
const failure = await onChange(url);
if (failure) toast.add({ type: "error", description: failure });
},
onError: (error) =>
toast.add({ type: "error", description: getReadableErrorMessage(error, t`Couldn't upload the photo.`) }),
onSettled: () => {
if (input.current) input.current.value = "";
},
});
};
return (
<div className="flex items-center gap-3.5">
<Avatar className="size-14">
<AvatarImage src={image ?? undefined} alt="" />
<AvatarFallback className="bg-accent-soft font-semibold text-accent-text text-lg">
{getInitials(name)}
</AvatarFallback>
</Avatar>
<input
ref={input}
type="file"
accept="image/*"
className="sr-only"
tabIndex={-1}
aria-hidden
onChange={(event) => choose(event.target.files?.[0])}
/>
<Button size="sm" variant="secondary" loading={upload.isPending} onClick={() => input.current?.click()}>
<Trans>Change photo</Trans>
</Button>
{image && (
<Button size="sm" variant="ghost" onClick={() => void onChange(null)}>
<Trans>Remove</Trans>
</Button>
)}
</div>
);
}
@@ -0,0 +1,168 @@
import type { AuthProvider } from "@reactive-resume/auth/types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import { Switch } from "@reactive-resume/ui/components/switch";
import { toast } from "@reactive-resume/ui/components/toast";
import { useDialogStore } from "@/dialogs/store";
import { usePrompt } from "@/hooks/use-prompt";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { SettingsRow, SettingsSection } from "../section";
import { getProviderName, useAuthAccounts, useAuthProviderActions, useEnabledProviders } from "./auth-hooks";
const SOCIAL: AuthProvider[] = ["google", "github", "linkedin", "custom"];
const monthYear = (date: Date) => date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
export function SecuritySection() {
const openDialog = useDialogStore((state) => state.openDialog);
const { hasAccount, getAccountByProviderId } = useAuthAccounts();
const { enabledProviders } = useEnabledProviders();
const { link, unlink } = useAuthProviderActions();
const { data: session } = authClient.useSession();
const password = getAccountByProviderId("credential");
const twoFactor = session?.user.twoFactorEnabled ?? false;
const social = SOCIAL.filter((provider) => provider in enabledProviders);
return (
<SettingsSection title={<Trans>Sign-in & security</Trans>}>
<SettingsRow
title={<Trans>Password</Trans>}
description={
password ? (
<Trans>Last changed in {monthYear(new Date(password.updatedAt))}</Trans>
) : (
<Trans>You sign in without one</Trans>
)
}
>
{password ? (
<Button size="sm" variant="secondary" onClick={() => openDialog("auth.change-password", undefined)}>
<Trans>Change</Trans>
</Button>
) : (
<Link to="/auth/forgot-password" className={buttonVariants({ size: "sm", variant: "secondary" })}>
<Trans>Set a password</Trans>
</Link>
)}
</SettingsRow>
{password && (
<SettingsRow
title={<Trans>Two-step verification</Trans>}
description={<Trans>A code from an authenticator app at sign-in</Trans>}
>
<Switch
aria-label={t`Two-step verification`}
checked={twoFactor}
// Turning it on walks through the QR code and backup codes; turning it off asks for the password.
onCheckedChange={() =>
openDialog(twoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined)
}
/>
</SettingsRow>
)}
<Passkeys />
{social.map((provider) => {
const account = getAccountByProviderId(provider);
const name =
provider === "custom" ? (enabledProviders.custom ?? getProviderName(provider)) : getProviderName(provider);
return (
<SettingsRow
key={provider}
title={name}
description={hasAccount(provider) ? <Trans>Connected</Trans> : <Trans>Not connected</Trans>}
>
{account ? (
<Button size="sm" variant="ghost" onClick={() => void unlink(provider, account.accountId)}>
<Trans>Disconnect</Trans>
</Button>
) : (
<Button size="sm" variant="secondary" onClick={() => void link(provider)}>
<Trans>Connect</Trans>
</Button>
)}
</SettingsRow>
);
})}
</SettingsSection>
);
}
const PASSKEYS_KEY = ["auth", "passkeys"];
function Passkeys() {
const queryClient = useQueryClient();
const prompt = usePrompt();
const { data: passkeys = [] } = useQuery({
queryKey: PASSKEYS_KEY,
queryFn: () => authClient.passkey.listUserPasskeys(),
select: ({ data }) => data ?? [],
});
const refresh = () => queryClient.invalidateQueries({ queryKey: PASSKEYS_KEY });
const failed = (error: unknown, fallback: string) =>
toast.add({ type: "error", description: getReadableErrorMessage(error, fallback) });
const add = useMutation({
mutationFn: async () => {
const { data, error } = await authClient.passkey.addPasskey();
if (error) return failed(error, t`Couldn't add the passkey. Try again.`);
await refresh();
const name = await prompt(t`Name this passkey`, {
description: t`A name helps you tell passkeys apart, like "Work laptop".`,
defaultValue: "",
confirmText: t`Save`,
});
const id = typeof data?.id === "string" ? data.id : null;
if (!id || !name?.trim()) return;
const renamed = await authClient.passkey.updatePasskey({ id, name: name.trim() });
if (renamed.error) return failed(renamed.error, t`Couldn't rename the passkey.`);
await refresh();
},
});
const remove = useMutation({
mutationFn: async (id: string) => {
const { error } = await authClient.passkey.deletePasskey({ id });
if (error) return failed(error, t`Couldn't remove the passkey. Try again.`);
await refresh();
},
});
return (
<div className="grid">
<SettingsRow
title={<Trans>Passkeys</Trans>}
description={<Trans>Sign in with your fingerprint, face or device PIN</Trans>}
>
<Button size="sm" variant="secondary" loading={add.isPending} onClick={() => add.mutate()}>
<Trans>Add passkey</Trans>
</Button>
</SettingsRow>
{passkeys.length > 0 && (
<ul className="ms-4 grid border-line border-s ps-3">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex items-center gap-3 py-1.5 text-sm">
<span className="min-w-0 flex-1 truncate">{passkey.name || t`Unnamed passkey`}</span>
<Button
size="sm"
variant="ghost"
disabled={remove.isPending}
aria-label={t`Remove ${passkey.name || t`Unnamed passkey`}`}
onClick={() => remove.mutate(passkey.id)}
>
<Trans>Remove</Trans>
</Button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const apiKey = vi.hoisted(() => ({ list: vi.fn(), update: vi.fn(), delete: vi.fn(), create: vi.fn() }));
const toasts = vi.hoisted(() => ({ add: vi.fn() }));
vi.mock("@/libs/auth/client", () => ({ authClient: { apiKey } }));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: toasts.add, close: vi.fn() } }));
i18n.loadAndActivate({ locale: "en", messages: {} });
const { ApiKeysSection } = await import("./api-keys");
const key = {
id: "key-1",
name: "Claude Desktop",
start: "rr_abc",
enabled: true,
lastRequest: null,
expiresAt: null,
createdAt: new Date("2026-08-16T10:00:00Z"),
};
type ToastOptions = { actionProps: { onClick: () => Promise<void> }; onClose: () => void };
async function revoke() {
render(
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<ApiKeysSection />
</QueryClientProvider>
</I18nProvider>,
);
await waitFor(() => expect(screen.getByText("Claude Desktop")).toBeTruthy());
expect(screen.getAllByText("Never")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "Revoke Claude Desktop" }));
await waitFor(() => expect(toasts.add).toHaveBeenCalled());
return toasts.add.mock.calls.at(-1)?.[0] as ToastOptions;
}
describe("API keys", () => {
beforeEach(() => {
vi.clearAllMocks();
apiKey.list.mockResolvedValue({ data: { apiKeys: [key] }, error: null });
apiKey.update.mockResolvedValue({ error: null });
apiKey.delete.mockResolvedValue({ error: null });
});
it("turns a revoked key off at once, and Undo turns it back on without deleting it", async () => {
const toast = await revoke();
expect(apiKey.update).toHaveBeenCalledWith({ keyId: "key-1", enabled: false });
await toast.actionProps.onClick();
toast.onClose();
expect(apiKey.update).toHaveBeenLastCalledWith({ keyId: "key-1", enabled: true });
expect(apiKey.delete).not.toHaveBeenCalled();
});
it("deletes the key for good once the toast goes without Undo", async () => {
const toast = await revoke();
toast.onClose();
expect(apiKey.delete).toHaveBeenCalledWith({ keyId: "key-1" });
});
});
@@ -0,0 +1,317 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { SettingsSection } from "../section";
const KEYS = ["auth", "api-keys"];
const DAY = 24 * 60 * 60;
type ApiKey = {
id: string;
name: string | null;
start: string | null;
createdAt: Date;
lastRequest: Date | null;
expiresAt: Date | null;
};
const shortDate = (date: Date) =>
date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
export function ApiKeysSection() {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const { data: keys = [], isLoading } = useQuery({
queryKey: KEYS,
queryFn: () => authClient.apiKey.list(),
// A revoked key is turned off at once and deleted when its Undo toast goes; it's never listed.
select: ({ data }) =>
(data?.apiKeys ?? [])
.filter((key) => key.enabled && (!key.expiresAt || key.expiresAt.getTime() > Date.now()))
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) as ApiKey[],
});
const refresh = () => queryClient.invalidateQueries({ queryKey: KEYS });
const revoke = async (key: ApiKey) => {
const { error } = await authClient.apiKey.update({ keyId: key.id, enabled: false });
if (error) {
toast.add({ type: "error", description: getReadableErrorMessage(error, t`Couldn't revoke the key.`) });
return;
}
await refresh();
let undone = false;
const name = key.name || key.start || t`the key`;
toast.add({
description: t`Revoked “${name}”. Apps using it stop working now.`,
actionProps: {
children: t`Undo`,
onClick: async () => {
undone = true;
await authClient.apiKey.update({ keyId: key.id, enabled: true });
await refresh();
},
},
onClose: () => {
if (!undone) void authClient.apiKey.delete({ keyId: key.id });
},
});
};
return (
<SettingsSection
title={<Trans>API keys</Trans>}
description={
<Trans>For scripts, extensions and MCP clients. Keys act as you and can read and edit your documents.</Trans>
}
action={
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
<Icon name="add" size={18} />
<Trans>New key</Trans>
</Button>
}
>
{isLoading ? null : keys.length === 0 ? (
<p className="rounded-xl border border-line-2 border-dashed p-4 text-ink-2 text-sm">
<Trans>No keys yet.</Trans>
</p>
) : (
<table className="w-full overflow-hidden rounded-xl text-[13px] outline outline-line max-sm:block">
<thead className="bg-bg text-ink-3 text-xs max-sm:hidden">
<tr className="h-9 text-start">
<th scope="col" className="ps-3.5 text-start font-medium">
<Trans>Name</Trans>
</th>
<th scope="col" className="text-start font-medium">
<Trans>Created</Trans>
</th>
<th scope="col" className="text-start font-medium">
<Trans>Last used</Trans>
</th>
<th scope="col" className="text-start font-medium">
<Trans>Expires</Trans>
</th>
<th scope="col" className="pe-3.5">
<span className="sr-only">
<Trans>Actions</Trans>
</span>
</th>
</tr>
</thead>
<tbody className="max-sm:block">
{keys.map((key) => (
<tr
key={key.id}
className="h-12 border-line border-t max-sm:flex max-sm:h-auto max-sm:flex-wrap max-sm:items-center max-sm:gap-x-3 max-sm:gap-y-0.5 max-sm:p-3 max-sm:first:border-t-0"
>
<td className="ps-3.5 font-medium max-sm:w-full max-sm:ps-0">
{key.name || <span className="font-mono text-ink-2">{key.start}…</span>}
</td>
<td className="text-ink-2 max-sm:text-xs">
<span className="sm:hidden">
<Trans>Created</Trans>{" "}
</span>
{shortDate(key.createdAt)}
</td>
<td className="text-ink-2 max-sm:text-xs">
<span className="sm:hidden">
<Trans>Last used</Trans>{" "}
</span>
{key.lastRequest ? shortDate(key.lastRequest) : <Trans>Never</Trans>}
</td>
<td className="text-ink-2 max-sm:text-xs">
<span className="sm:hidden">
<Trans>Expires</Trans>{" "}
</span>
{key.expiresAt ? shortDate(key.expiresAt) : <Trans>Never</Trans>}
</td>
<td className="pe-2 text-end max-sm:ms-auto max-sm:pe-0">
<Button
size="sm"
variant="ghost"
className="text-danger-text hover:bg-danger-soft"
aria-label={t`Revoke ${key.name || key.start || ""}`}
onClick={() => void revoke(key)}
>
<Trans>Revoke</Trans>
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<NewKeyDialog open={creating} onOpenChange={setCreating} onCreated={() => void refresh()} />
</SettingsSection>
);
}
type Expiry = { label: () => string; seconds: number | null };
const EXPIRIES: Expiry[] = [
{ label: () => t`30 days`, seconds: 30 * DAY },
{ label: () => t`90 days`, seconds: 90 * DAY },
{ label: () => t`Never`, seconds: null },
];
type NewKeyDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void };
/** Name and expiry, then the key, shown once with Copy. */
function NewKeyDialog({ open, onOpenChange, onCreated }: NewKeyDialogProps) {
const id = useId();
const [name, setName] = useState("");
const [expiry, setExpiry] = useState(0);
const [key, setKey] = useState<string | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [copied, setCopied] = useState(false);
const [, copy] = useCopyToClipboard();
const close = (next: boolean) => {
onOpenChange(next);
if (next) return;
setName("");
setExpiry(0);
setKey(null);
setFailure(null);
setCopied(false);
};
const create = async () => {
setCreating(true);
setFailure(null);
const { data, error } = await authClient.apiKey.create({
name: name.trim(),
expiresIn: EXPIRIES[expiry]?.seconds ?? null,
});
setCreating(false);
if (error || !data) return setFailure(getReadableErrorMessage(error, t`Couldn't create the key. Try again.`));
setKey(data.key);
onCreated();
};
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
<Trans>New API key</Trans>
</DialogTitle>
<DialogDescription className={cn(key && "sr-only")}>
<Trans>Keys act as you and can read and edit your documents.</Trans>
</DialogDescription>
</DialogHeader>
{key ? (
<div className="grid gap-3">
<p
role="status"
className="flex gap-2.5 rounded-[10px] bg-warn-soft px-3 py-2.5 text-[13px] text-warn-text"
>
<Icon name="key" size={20} />
<Trans>Copy it now. For your security, it won't be shown again.</Trans>
</p>
<div className="flex h-[42px] items-center gap-2 rounded-lg border border-line-2 bg-bg ps-3 pe-1.5 font-mono text-[13px]">
<span className="min-w-0 flex-1 truncate">{key}</span>
<Button
size="sm"
variant="secondary"
onClick={async () => {
await copy(key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
<Icon name={copied ? "check" : "content_copy"} size={16} />
{copied ? <Trans>Copied</Trans> : <Trans>Copy</Trans>}
</Button>
</div>
<DialogFooter>
<Button onClick={() => close(false)}>
<Trans>Done</Trans>
</Button>
</DialogFooter>
</div>
) : (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
if (name.trim()) void create();
}}
>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-name`}>
<Trans>What's it for?</Trans>
</Label>
<Input
id={`${id}-name`}
value={name}
maxLength={64}
placeholder={t`Claude Desktop`}
onChange={(event) => setName(event.target.value)}
/>
</div>
<fieldset className="grid gap-1.5">
<legend className="mb-1.5 font-medium text-sm">
<Trans>Expires</Trans>
</legend>
<div className="grid grid-cols-3 gap-2">
{EXPIRIES.map((option, index) => (
<label
key={option.label()}
className={cn(
"flex h-9 cursor-pointer items-center justify-center rounded-lg border border-line-2 font-medium text-sm transition-colors duration-quick hover:bg-hover has-focus-visible:outline-2 has-focus-visible:outline-accent",
expiry === index && "border-accent bg-accent-soft text-accent-text",
)}
>
<input
type="radio"
name={`${id}-expiry`}
className="sr-only"
checked={expiry === index}
onChange={() => setExpiry(index)}
/>
{option.label()}
</label>
))}
</div>
</fieldset>
{failure && (
<p role="alert" className="text-danger-text text-sm">
{failure}
</p>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => close(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={!name.trim() || creating} loading={creating}>
<Trans>Create key</Trans>
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { describeTest, keyEnding, providerOptions } from "./catalog";
const labels = { connected: "Connected", failed: "Failed" };
describe("provider rows", () => {
it("says how long a passing test took, rounded to the millisecond", () => {
expect(describeTest({ testStatus: "success", testError: null }, 419.6, labels)).toEqual({
ok: true,
label: "Connected · 420 ms",
error: null,
});
});
it("keeps the provider's own error for a failing test", () => {
expect(describeTest({ testStatus: "failure", testError: " 401 Incorrect API key " }, 90, labels)).toEqual({
ok: false,
label: "Failed",
error: "401 Incorrect API key",
});
expect(describeTest({ testStatus: "failure", testError: null }, 90, labels).error).toBeNull();
});
it("shows only the key's last four characters", () => {
expect(keyEnding("sk-a...9f2a")).toBe("…9f2a");
expect(keyEnding("••••")).toBe("••••");
});
it("offers all sixteen providers", () => {
expect(providerOptions).toHaveLength(16);
});
});
@@ -0,0 +1,153 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ComboboxOption } from "@/components/ui/combobox";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
export type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string; defaultModel: string };
// Provider labels are brand names and stay untranslated on purpose, so every locale shows the
// vendor's own spelling instead of a literal translation (e.g. "Google Gemini", not "谷歌双子座").
export const providerOptions: AIProviderOption[] = [
{
value: "openai",
label: "OpenAI",
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openai,
defaultModel: "gpt-4.1",
},
{
value: "anthropic",
label: "Anthropic Claude",
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.anthropic,
defaultModel: "claude-3-5-sonnet-latest",
},
{
value: "gemini",
label: "Google Gemini",
keywords: ["gemini", "google"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.gemini,
defaultModel: "gemini-2.0-flash",
},
{
value: "vercel-ai-gateway",
label: "Vercel AI Gateway",
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["vercel-ai-gateway"],
defaultModel: "openai/gpt-4.1",
},
{
value: "openrouter",
label: "OpenRouter",
keywords: ["openrouter", "router"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
defaultModel: "openai/gpt-4.1",
},
{
value: "mistral",
label: "Mistral AI",
keywords: ["mistral", "magistral"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.mistral,
defaultModel: "mistral-large-latest",
},
{
value: "cohere",
label: "Cohere",
keywords: ["cohere", "command"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.cohere,
defaultModel: "command-a-03-2025",
},
{
value: "xai",
label: "xAI Grok",
keywords: ["xai", "grok"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.xai,
defaultModel: "grok-4",
},
{
value: "groq",
label: "Groq",
keywords: ["groq", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.groq,
defaultModel: "llama-3.3-70b-versatile",
},
{
value: "deepseek",
label: "DeepSeek",
keywords: ["deepseek"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.deepseek,
defaultModel: "deepseek-chat",
},
{
value: "togetherai",
label: "Together.ai",
keywords: ["together", "togetherai", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.togetherai,
defaultModel: "meta-llama/Meta-Llama-3.3-70B-Instruct-Turbo",
},
{
value: "fireworks",
label: "Fireworks",
keywords: ["fireworks", "llama", "deepseek"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.fireworks,
defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct",
},
{
value: "cerebras",
label: "Cerebras",
keywords: ["cerebras", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.cerebras,
defaultModel: "llama3.3-70b",
},
{
value: "perplexity",
label: "Perplexity",
keywords: ["perplexity", "sonar"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.perplexity,
defaultModel: "sonar-pro",
},
{
value: "ollama",
label: "Ollama Cloud",
keywords: ["ollama", "cloud"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
defaultModel: "llama3.1",
},
{
value: "openai-compatible",
label: "OpenAI-compatible",
keywords: ["compatible", "custom", "gateway"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["openai-compatible"],
defaultModel: "",
},
];
// Prefill Base URL + Model from the provider's known defaults when the provider changes.
export function providerDefaults(provider: AIProvider) {
const option = providerOptions.find((entry) => entry.value === provider);
return {
baseURL: option?.defaultBaseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider] ?? "",
model: option?.defaultModel ?? "",
};
}
export function providerLabel(provider: AIProvider) {
return String(providerOptions.find((option) => option.value === provider)?.label ?? provider);
}
/** "…9f2a" from the stored preview ("sk-a...9f2a"); short keys only ever show dots. */
export function keyEnding(preview: string) {
const [, end] = preview.split("...");
return end ? `…${end}` : preview;
}
type TestResult = { testStatus: string; testError: string | null };
/**
* What Test says after it runs: "Connected · 420 ms", with the round trip measured in the browser, or the provider's
* own error so it can be fixed here.
*/
export function describeTest(result: TestResult, milliseconds: number, labels: { connected: string; failed: string }) {
if (result.testStatus === "success")
return { ok: true, label: `${labels.connected} · ${Math.round(milliseconds)} ms`, error: null };
return { ok: false, label: labels.failed, error: result.testError?.trim() || null };
}
+44
View File
@@ -0,0 +1,44 @@
import { Trans } from "@lingui/react/macro";
import { useState } from "react";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import { SettingsSection } from "../section";
export function McpSection() {
const address = typeof window === "undefined" ? "/mcp" : `${window.location.origin}/mcp`;
const [copied, setCopied] = useState(false);
const [, copy] = useCopyToClipboard();
return (
<SettingsSection
title={<Trans>MCP server</Trans>}
description={
<Trans>Let AI clients like Claude or Cursor read and edit your resumes, using an API key above.</Trans>
}
>
<div className="flex h-[38px] items-center rounded-lg border border-line-2 bg-raised ps-3 pe-1.5 font-medium font-mono text-[13px]">
<span className="min-w-0 flex-1 truncate">{address}</span>
<Button
size="sm"
variant="secondary"
aria-live="polite"
onClick={async () => {
await copy(address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? <Trans>Copied</Trans> : <Trans>Copy</Trans>}
</Button>
</div>
<a
href="https://docs.rxresu.me/guides/using-the-mcp-server"
target="_blank"
rel="noopener noreferrer"
className="w-fit text-ink-2 text-sm underline underline-offset-2"
>
<Trans>Setup guide</Trans>
</a>
</SettingsSection>
);
}
@@ -0,0 +1,14 @@
import { ApiKeysSection } from "./api-keys";
import { McpSection } from "./mcp";
import { ProvidersSection } from "./providers";
/** Providers and API keys together: both connect outside tools. */
export function AiDeveloperSettings() {
return (
<>
<ProvidersSection />
<ApiKeysSection />
<McpSection />
</>
);
}
@@ -0,0 +1,480 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { SettingsSection } from "../section";
import { describeTest, keyEnding, providerDefaults, providerLabel, providerOptions } from "./catalog";
type SavedProvider = RouterOutput["aiProviders"]["list"][number];
const secretInputProps = {
autoCorrect: "off",
autoCapitalize: "off",
spellCheck: false,
"data-lpignore": "true",
"data-1p-ignore": "true",
"data-bwignore": "true",
} as const;
const isConfigError = (error: unknown) => error instanceof ORPCError && error.code === "PRECONDITION_FAILED";
export function ProvidersSection() {
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<SavedProvider | null>(null);
return (
<SettingsSection title={<Trans>AI providers</Trans>} description={<Trans>Your keys, stored encrypted</Trans>}>
{error ? (
<p role="alert" className="rounded-lg bg-warn-soft p-3 text-sm text-warn-text">
{isConfigError(error) ? (
<Trans>AI providers aren't available on this server until ENCRYPTION_SECRET is set.</Trans>
) : (
<Trans>AI providers couldn't be loaded. Reload to try again.</Trans>
)}
</p>
) : isLoading ? (
<p className="flex items-center gap-2 text-ink-3 text-sm">
<Spinner decorative className="size-3.5" />
<Trans>Loading providers…</Trans>
</p>
) : (
<>
{providers?.map((provider) => (
<ProviderRow key={provider.id} provider={provider} onEdit={() => setEditing(provider)} />
))}
<button
type="button"
onClick={() => setAdding(true)}
className="flex h-10 items-center gap-2 rounded-[10px] border border-line-2 border-dashed px-3 text-start text-sm transition-colors duration-quick hover:bg-hover"
>
<Icon name="add" size={20} />
<Trans>Add provider · Anthropic, Gemini, Ollama, OpenAI-compatible</Trans>
</button>
</>
)}
<AddProviderDialog open={adding} onOpenChange={setAdding} />
{editing && <EditProviderDialog provider={editing} onClose={() => setEditing(null)} />}
</SettingsSection>
);
}
type TestState = { ok: boolean; label: string; error: string | null } | null;
/** Runs a provider's connection test and times it, so the row can say "Connected · 420 ms" or the exact error. */
function useProviderTest() {
const queryClient = useQueryClient();
const [result, setResult] = useState<TestState>(null);
const test = useMutation(orpc.aiProviders.test.mutationOptions({ meta: { noInvalidate: true } }));
const run = async (id: string) => {
setResult(null);
const started = performance.now();
try {
const tested = await test.mutateAsync({ id });
const next = describeTest(tested, performance.now() - started, { connected: t`Connected`, failed: t`Failed` });
setResult(next);
return next;
} catch (error) {
const next = {
ok: false,
label: t`Failed`,
error: getOrpcErrorMessage(error, { fallback: t`The test didn't run.` }),
};
setResult(next);
return next;
} finally {
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
}
};
return { run, result, isPending: test.isPending };
}
type ProviderRowProps = { provider: SavedProvider; onEdit: () => void };
function ProviderRow({ provider, onEdit }: ProviderRowProps) {
const test = useProviderTest();
const error = test.result ? test.result.error : provider.testStatus === "failure" ? provider.testError : null;
return (
<div className="grid gap-1.5 py-1">
<div className="flex flex-wrap items-center gap-3">
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-lg bg-sunken font-semibold text-ink-2 text-xs"
>
{providerLabel(provider.provider).slice(0, 2)}
</span>
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="flex items-center gap-2 font-semibold text-sm">
{provider.label}
{!provider.enabled && (
<span className="rounded-full bg-sunken px-2 font-medium text-ink-3 text-xs">
<Trans>Off</Trans>
</span>
)}
</span>
<span className="truncate text-ink-3 text-xs">
<Trans>
Model {provider.model} · key ends in {keyEnding(provider.apiKeyPreview)}
</Trans>
</span>
</span>
<Button
size="sm"
variant="secondary"
aria-live="polite"
disabled={test.isPending}
className={cn(test.result?.ok && "text-accent-text", test.result && !test.result.ok && "text-danger-text")}
onClick={() => void test.run(provider.id)}
>
{test.isPending ? (
<>
<Spinner decorative className="size-3.5" />
<Trans>Testing…</Trans>
</>
) : (
(test.result?.label ?? <Trans>Test</Trans>)
)}
</Button>
<Button size="sm" variant="ghost" onClick={onEdit}>
<Trans>Edit</Trans>
</Button>
</div>
{error && (
<p role="alert" className="ms-12 text-danger-text text-xs">
{error}
</p>
)}
</div>
);
}
type ProviderFields = { label: string; model: string; baseURL: string; apiKey: string };
type ProviderFieldsProps = {
provider: AIProvider;
value: ProviderFields;
onChange: (value: ProviderFields) => void;
/** Editing: an empty key keeps the saved one. */
keyOptional?: boolean;
};
function ProviderFieldsForm({ provider, value, onChange, keyOptional }: ProviderFieldsProps) {
const id = useId();
const set = (patch: Partial<ProviderFields>) => onChange({ ...value, ...patch });
const defaults = providerDefaults(provider);
return (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5 sm:col-span-2">
<Label htmlFor={`${id}-key`}>
<Trans>API key</Trans>
</Label>
<Input
id={`${id}-key`}
type="password"
value={value.apiKey}
placeholder={keyOptional ? t`Leave empty to keep the saved key` : undefined}
onChange={(event) => set({ apiKey: event.target.value })}
{...secretInputProps}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-model`}>
<Trans>Model</Trans>
</Label>
<Input
id={`${id}-model`}
value={value.model}
placeholder={defaults.model || "gpt-4.1"}
onChange={(event) => set({ model: event.target.value })}
{...secretInputProps}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-label`}>
<Trans>Name</Trans>
</Label>
<Input
id={`${id}-label`}
value={value.label}
placeholder={providerLabel(provider)}
onChange={(event) => set({ label: event.target.value })}
/>
</div>
<div className="grid gap-1.5 sm:col-span-2">
<Label htmlFor={`${id}-url`}>
<Trans>Base URL</Trans>
</Label>
<Input
id={`${id}-url`}
type="url"
value={value.baseURL}
placeholder={defaults.baseURL || "https://gateway.example.com/v1"}
onChange={(event) => set({ baseURL: event.target.value })}
{...secretInputProps}
/>
</div>
</div>
);
}
type AddProviderDialogProps = { open: boolean; onOpenChange: (open: boolean) => void };
/** Every supported provider; saving tests the connection at once. */
function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) {
const id = useId();
const queryClient = useQueryClient();
const [provider, setProvider] = useState<AIProvider>("openai");
const [fields, setFields] = useState<ProviderFields>({ label: "", apiKey: "", ...providerDefaults("openai") });
const [failure, setFailure] = useState<string | null>(null);
const create = useMutation(orpc.aiProviders.create.mutationOptions({ meta: { noInvalidate: true } }));
const test = useMutation(orpc.aiProviders.test.mutationOptions({ meta: { noInvalidate: true } }));
const reset = () => {
setProvider("openai");
setFields({ label: "", apiKey: "", ...providerDefaults("openai") });
setFailure(null);
};
const save = async () => {
setFailure(null);
try {
const created = await create.mutateAsync({
label: fields.label.trim() || providerLabel(provider),
provider,
model: fields.model.trim(),
baseURL: fields.baseURL.trim(),
apiKey: fields.apiKey.trim(),
});
const tested = await test.mutateAsync({ id: created.id });
if (tested.testStatus === "success") {
onOpenChange(false);
reset();
} else {
// The provider is saved either way; its row keeps the error and a Test button.
setFailure(tested.testError ?? t`The provider didn't answer. Check the key, the model and the base URL.`);
}
} catch (error) {
setFailure(
getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI providers aren't available on this server until ENCRYPTION_SECRET is set.`,
BAD_REQUEST: t`That provider setup isn't valid. Check the base URL.`,
},
fallback: t`Couldn't save the provider.`,
}),
);
} finally {
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
}
};
const pending = create.isPending || test.isPending;
const ready = Boolean(fields.apiKey.trim() && fields.model.trim());
return (
<Dialog
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) reset();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Add provider</Trans>
</DialogTitle>
<DialogDescription>
<Trans>The key is encrypted on the server and never shown again. Saving tests the connection.</Trans>
</DialogDescription>
</DialogHeader>
<form
className="grid gap-3"
onSubmit={(event) => {
event.preventDefault();
if (ready && !pending) void save();
}}
>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-provider`}>
<Trans>Provider</Trans>
</Label>
<Combobox
id={`${id}-provider`}
value={provider}
showClear={false}
options={providerOptions}
onValueChange={(next) => {
if (!next) return;
setProvider(next);
setFields((current) => ({ ...current, ...providerDefaults(next) }));
}}
/>
</div>
<ProviderFieldsForm provider={provider} value={fields} onChange={setFields} />
{failure && (
<p role="alert" className="text-danger-text text-sm">
{failure}
</p>
)}
<DialogFooter>
<Button type="submit" disabled={!ready || pending} loading={pending}>
{test.isPending ? <Trans>Testing the connection…</Trans> : <Trans>Save and test</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
type EditProviderDialogProps = { provider: SavedProvider; onClose: () => void };
/** Name, model, base URL and key; whether the app may use it; and removing it. */
function EditProviderDialog({ provider, onClose }: EditProviderDialogProps) {
const queryClient = useQueryClient();
const [fields, setFields] = useState<ProviderFields>({
label: provider.label,
model: provider.model,
baseURL: provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider] ?? "",
apiKey: "",
});
const [failure, setFailure] = useState<string | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
const update = useMutation(orpc.aiProviders.update.mutationOptions({ meta: { noInvalidate: true } }));
const remove = useMutation(orpc.aiProviders.delete.mutationOptions({ meta: { noInvalidate: true } }));
const test = useProviderTest();
const changes = {
...(fields.label.trim() && fields.label.trim() !== provider.label ? { label: fields.label.trim() } : {}),
...(fields.model.trim() && fields.model.trim() !== provider.model ? { model: fields.model.trim() } : {}),
...(fields.baseURL.trim() !== (provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider] ?? "")
? { baseURL: fields.baseURL.trim() }
: {}),
...(fields.apiKey.trim() ? { apiKey: fields.apiKey.trim() } : {}),
};
const changed = Object.keys(changes).length > 0;
const save = async () => {
setFailure(null);
try {
await update.mutateAsync({ id: provider.id, ...changes });
// A new model, key or address needs a fresh test before the app uses it.
const result = await test.run(provider.id);
if (result.ok) onClose();
else setFailure(result.error ?? t`The provider didn't answer.`);
} catch (error) {
setFailure(getOrpcErrorMessage(error, { fallback: t`Couldn't save the provider.` }));
} finally {
void invalidate();
}
};
const setEnabled = (enabled: boolean) =>
update.mutate(
{ id: provider.id, enabled },
{
onSuccess: () => void invalidate(),
onError: (error) => setFailure(getOrpcErrorMessage(error, { fallback: t`Couldn't change it.` })),
},
);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{provider.label}</DialogTitle>
<DialogDescription>{providerLabel(provider.provider)}</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
if (changed) void save();
}}
>
<ProviderFieldsForm provider={provider.provider} value={fields} onChange={setFields} keyOptional />
<div className="flex items-center justify-between gap-3 text-sm">
<span className="grid gap-0.5">
<span id={`${provider.id}-use`} className="font-medium">
<Trans>Use this provider</Trans>
</span>
<span id={`${provider.id}-use-hint`} className="text-ink-3 text-xs">
<Trans>Only providers that pass their test can be turned on.</Trans>
</span>
</span>
<Switch
aria-labelledby={`${provider.id}-use`}
aria-describedby={`${provider.id}-use-hint`}
checked={provider.enabled}
disabled={provider.testStatus !== "success" || update.isPending}
onCheckedChange={setEnabled}
/>
</div>
{failure && (
<p role="alert" className="text-danger-text text-sm">
{failure}
</p>
)}
<DialogFooter className="sm:justify-between">
<Button
type="button"
variant="ghost"
className="text-danger-text hover:bg-danger-soft"
loading={remove.isPending}
onClick={() =>
remove.mutate(
{ id: provider.id },
{
onSuccess: () => {
void invalidate();
onClose();
},
onError: (error) =>
setFailure(getOrpcErrorMessage(error, { fallback: t`Couldn't delete the provider.` })),
},
)
}
>
<Trans>Delete provider</Trans>
</Button>
<Button
type="submit"
disabled={!changed || update.isPending || test.isPending}
loading={update.isPending || test.isPending}
>
<Trans>Save and test</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,165 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { KeyIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { toast } from "@reactive-resume/ui/components/toast";
import { usePrompt } from "@/hooks/use-prompt";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
export function PasskeysSection() {
const queryClient = useQueryClient();
const prompt = usePrompt();
const { data: passkeys = [] } = useQuery({
queryKey: ["auth", "passkeys"],
queryFn: () => authClient.passkey.listUserPasskeys(),
select: ({ data }) => data ?? [],
});
const registerPasskeyMutation = useMutation({
mutationFn: () => authClient.passkey.addPasskey(),
onSuccess: async ({ data, error }) => {
if (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when passkey registration fails",
message: "Failed to register passkey. Please try again.",
}),
),
});
return;
}
toast.add({ type: "success", description: t`Passkey registered.` });
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
const name = await prompt(t`Enter a name for your passkey.`, {
description: t`Names help you tell passkeys apart if you register more than one.`,
defaultValue: "",
confirmText: t({
comment: "Passkey rename prompt confirm action in authentication settings",
message: "Save",
}),
});
if (name === null) return;
const passkeyId = typeof data?.id === "string" ? data.id : null;
const passkeyName = name.trim();
if (!passkeyId || passkeyName.length === 0) return;
const { error: renameError } = await authClient.passkey.updatePasskey({ id: passkeyId, name: passkeyName });
if (renameError) {
toast.add({
type: "error",
description: getReadableErrorMessage(
renameError,
t({
comment: "Fallback toast when renaming a passkey fails",
message: "Failed to rename passkey. Please try again.",
}),
),
});
return;
}
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
},
onError: () => {
toast.add({ type: "error", description: t`Failed to register passkey. Please try again.` });
},
});
const deletePasskeyMutation = useMutation({
mutationFn: (id: string) => authClient.passkey.deletePasskey({ id }),
onSuccess: async ({ error }) => {
if (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when deleting a passkey fails",
message: "Failed to delete passkey. Please try again.",
}),
),
});
return;
}
toast.add({ type: "success", description: t`Passkey deleted.` });
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
},
onError: () => {
toast.add({ type: "error", description: t`Failed to delete passkey. Please try again.` });
},
});
const handleRegisterPasskey = () => {
if (registerPasskeyMutation.isPending) return;
registerPasskeyMutation.mutate();
};
const handleDeletePasskey = (id: string) => {
if (deletePasskeyMutation.isPending) return;
deletePasskeyMutation.mutate(id);
};
return (
<div>
<Separator />
<div className="mt-4 grid gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-x-3 font-medium text-base">
<KeyIcon />
<Trans>Passkeys</Trans>
</h2>
<Button variant="secondary" onClick={handleRegisterPasskey} disabled={registerPasskeyMutation.isPending}>
<PlusIcon />
<Trans>Register New Device</Trans>
</Button>
</div>
{passkeys.length === 0 && (
<p className="text-muted-foreground text-sm">
<Trans>No passkeys registered yet.</Trans>
</p>
)}
{passkeys.length > 0 && (
<div className="grid gap-2">
{passkeys.map((passkey) => {
return (
<div
key={passkey.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2"
>
<p className="truncate font-medium text-sm">{passkey.name ?? t`Unnamed passkey`}</p>
<div className="flex items-center gap-2">
<Button
variant="danger"
size="sm"
onClick={() => handleDeletePasskey(passkey.id)}
disabled={deletePasskeyMutation.isPending}
>
<TrashIcon />
<Trans comment="Passkey row action to remove the selected passkey">Delete</Trans>
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -1,39 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { PasswordIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
import { useAuthAccounts } from "./hooks";
export function PasswordSection() {
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
const hasPassword = hasAccount("credential");
return (
<div className="flex items-center justify-between gap-x-4">
<h2 className="flex items-center gap-x-3 font-medium text-base">
<PasswordIcon />
<Trans>Password</Trans>
</h2>
{hasPassword ? (
<Button variant="secondary" onClick={() => openDialog("auth.change-password", undefined)}>
<PencilSimpleLineIcon />
<Trans>Update Password</Trans>
</Button>
) : (
<Button
variant="secondary"
nativeButton={false}
render={
<Link to="/auth/forgot-password">
<Trans>Set Password</Trans>
</Link>
}
/>
)}
</div>
);
}
@@ -1,54 +0,0 @@
import type { AuthProvider } from "@reactive-resume/auth/types";
import { Trans } from "@lingui/react/macro";
import { LinkBreakIcon, LinkIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { getProviderIcon, getProviderName, useAuthAccounts, useAuthProviderActions } from "./hooks";
type SocialProviderSectionProps = {
provider: AuthProvider;
name?: string;
};
export function SocialProviderSection({ provider, name }: SocialProviderSectionProps) {
const { link, unlink } = useAuthProviderActions();
const { hasAccount, getAccountByProviderId } = useAuthAccounts();
const providerName = name ?? getProviderName(provider);
const providerIcon = getProviderIcon(provider);
const account = getAccountByProviderId(provider);
const isConnected = hasAccount(provider);
return (
<div>
<Separator />
<div className="mt-4 flex items-center justify-between gap-x-4">
<h2 className="flex items-center gap-x-3 font-medium text-base">
{providerIcon}
{providerName}
</h2>
{isConnected ? (
<Button
variant="secondary"
onClick={() => {
if (account?.accountId) void unlink(provider, account.accountId);
}}
>
<LinkBreakIcon />
<Trans comment="Authentication settings action to unlink a connected social login provider">
Disconnect
</Trans>
</Button>
) : (
<Button variant="secondary" onClick={() => void link(provider)}>
<LinkIcon />
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
</Button>
)}
</div>
</div>
);
}
@@ -1,48 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { KeyIcon, LockOpenIcon, ToggleLeftIcon, ToggleRightIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { useDialogStore } from "@/dialogs/store";
import { authClient } from "@/libs/auth/client";
import { useAuthAccounts } from "./hooks";
export function TwoFactorSection() {
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
const { data: session } = authClient.useSession();
const hasPassword = hasAccount("credential");
const hasTwoFactor = session?.user.twoFactorEnabled ?? false;
if (!hasPassword) return null;
return (
<div>
<Separator />
<div className="mt-4 flex items-center justify-between gap-x-4">
<h2 className="flex items-center gap-x-3 font-medium text-base">
{hasTwoFactor ? <LockOpenIcon /> : <KeyIcon />}
<Trans>Two-Factor Authentication</Trans>
</h2>
<Button
variant="secondary"
onClick={() => openDialog(hasTwoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined)}
>
{hasTwoFactor ? (
<>
<ToggleLeftIcon />
<Trans>Disable 2FA</Trans>
</>
) : (
<>
<ToggleRightIcon />
<Trans>Enable 2FA</Trans>
</>
)}
</Button>
</div>
</div>
);
}
@@ -1,27 +0,0 @@
import { useEnabledProviders } from "./components/hooks";
import { PasskeysSection } from "./components/passkeys";
import { PasswordSection } from "./components/password";
import { SocialProviderSection } from "./components/social-provider";
import { TwoFactorSection } from "./components/two-factor";
export function AuthenticationSettingsPage() {
const { enabledProviders } = useEnabledProviders();
return (
<div className="grid max-w-xl gap-4">
<PasswordSection />
<TwoFactorSection />
<PasskeysSection />
{"google" in enabledProviders && <SocialProviderSection provider="google" />}
{"github" in enabledProviders && <SocialProviderSection provider="github" />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" />}
{"custom" in enabledProviders && <SocialProviderSection provider="custom" name={enabledProviders.custom} />}
</div>
);
}
@@ -1,239 +0,0 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { toast } from "@reactive-resume/ui/components/toast";
type MutationName = "create" | "test" | "update" | "delete";
type MockProvider = {
id: string;
label: string;
provider: "openai";
model: string;
baseURL: string;
enabled: boolean;
testStatus: string;
testError: string | null;
apiKeyPreview: string;
apiKeyFingerprint: string;
lastTestedAt: Date | null;
lastUsedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type MutationOptions = {
name: MutationName;
meta?: { noInvalidate?: boolean };
};
type ComboboxProps = {
id: string;
value: string;
options: { value: string; label: string }[];
onValueChange: (value: string) => void;
};
const queryClient = vi.hoisted(() => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
}));
const providers = vi.hoisted(() => ({ data: [] as MockProvider[] }));
const mutations = vi.hoisted(() => ({
create: vi.fn(),
test: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
}));
const mutationOptions = vi.hoisted(() => ({
create: vi.fn((options?: MutationOptions) => ({ ...options, name: "create" })),
test: vi.fn((options?: MutationOptions) => ({ ...options, name: "test" })),
update: vi.fn((options?: MutationOptions) => ({ ...options, name: "update" })),
delete: vi.fn((options?: MutationOptions) => ({ ...options, name: "delete" })),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: providers.data, isLoading: false, error: null }),
useQueryClient: () => queryClient,
useMutation: (options: MutationOptions) => ({
isPending: false,
mutate: (
input: unknown,
handlers?: { onSuccess?: (data: unknown) => void; onError?: (error: unknown) => void },
) => {
mutations[options.name](input).then(handlers?.onSuccess).catch(handlers?.onError);
},
mutateAsync: mutations[options.name],
}),
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
aiProviders: {
list: {
queryOptions: () => ({ queryKey: ["aiProviders", "list"] }),
queryKey: () => ["aiProviders", "list"],
},
create: { mutationOptions: mutationOptions.create },
test: { mutationOptions: mutationOptions.test },
update: { mutationOptions: mutationOptions.update },
delete: { mutationOptions: mutationOptions.delete },
},
},
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({ id, value, options, onValueChange }: ComboboxProps) => (
<select id={id} value={value} onChange={(event) => onValueChange(event.currentTarget.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: vi.fn() } }));
i18n.loadAndActivate({ locale: "en", messages: {} });
const { AISettingsSection } = await import("./ai-section");
const provider = (overrides: Partial<MockProvider>): MockProvider => ({
id: "provider-1",
label: "OpenAI",
provider: "openai",
model: "gpt-4.1",
baseURL: "https://api.openai.com/v1",
enabled: false,
testStatus: "untested",
testError: null,
apiKeyPreview: "sk-p...test",
apiKeyFingerprint: "fingerprint",
lastTestedAt: null,
lastUsedAt: null,
createdAt: new Date("2026-07-04T00:00:00Z"),
updatedAt: new Date("2026-07-04T00:00:00Z"),
...overrides,
});
const renderSection = () =>
render(
<I18nProvider i18n={i18n}>
<AISettingsSection />
</I18nProvider>,
);
describe("AISettingsSection", () => {
beforeEach(() => {
queryClient.invalidateQueries.mockReset();
queryClient.setQueryData.mockReset();
mutationOptions.create.mockClear();
mutationOptions.test.mockClear();
mutationOptions.update.mockClear();
mutationOptions.delete.mockClear();
mutations.create.mockReset();
mutations.test.mockReset();
mutations.update.mockReset();
mutations.delete.mockReset();
providers.data = [];
});
it("offers popular AI SDK providers and labels Ollama as cloud-hosted", () => {
renderSection();
for (const label of [
"Mistral AI",
"Cohere",
"xAI Grok",
"Groq",
"DeepSeek",
"Together.ai",
"Fireworks",
"Cerebras",
"Perplexity",
"Ollama Cloud",
]) {
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
}
expect(screen.queryByRole("option", { name: "Ollama" })).not.toBeInTheDocument();
});
it("uses the save-and-test result as the connected provider row", async () => {
const created = provider({});
const tested = provider({ enabled: true, testStatus: "success", lastTestedAt: new Date("2026-07-04T01:00:00Z") });
mutations.create.mockResolvedValue(created);
mutations.test.mockResolvedValue(tested);
renderSection();
fireEvent.change(screen.getByLabelText("API Key"), { target: { value: "sk-test" } });
fireEvent.click(screen.getByRole("button", { name: /save & test provider/i }));
await waitFor(() => expect(queryClient.setQueryData).toHaveBeenCalled());
expect(mutationOptions.create).toHaveBeenCalledWith({ meta: { noInvalidate: true } });
expect(mutationOptions.test).toHaveBeenCalledWith({ meta: { noInvalidate: true } });
expect(mutations.update).not.toHaveBeenCalled();
const [, updater] = queryClient.setQueryData.mock.calls.at(-1) as [
unknown,
(providers: MockProvider[]) => MockProvider[],
];
expect(updater([created])).toEqual([tested]);
});
// The server returns a provider-side failure as data (see the API package's e2e coverage), so the
// reason has to reach the card rather than being flattened into a generic transport error.
it("shows the server's reason on the card and keeps the toast to the outcome", async () => {
const failed = provider({
enabled: false,
testStatus: "failure",
testError: "OpenAI rejected the API key.",
lastTestedAt: new Date("2026-08-15T00:00:00Z"),
});
providers.data = [provider({})];
mutations.test.mockResolvedValue(failed);
renderSection();
fireEvent.click(screen.getByRole("button", { name: "Test" }));
await waitFor(() => expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ type: "error" })));
// Toast reports only the outcome; the card carries the detail.
expect(toast.add).toHaveBeenCalledWith({ type: "error", description: "Connection failed." });
expect(toast.add).not.toHaveBeenCalledWith(
expect.objectContaining({ description: expect.stringContaining("rejected the API key") }),
);
providers.data = [failed];
renderSection();
expect(screen.getAllByText("OpenAI rejected the API key.").length).toBeGreaterThan(0);
expect(screen.getAllByText("Connection failed").length).toBeGreaterThan(0);
});
it("updates a configured provider's model", async () => {
providers.data = [provider({})];
mutations.update.mockResolvedValue(provider({ model: "gpt-5-mini", testStatus: "untested", enabled: false }));
renderSection();
fireEvent.click(screen.getByRole("button", { name: "Edit model" }));
fireEvent.change(screen.getByLabelText("Provider model"), { target: { value: "gpt-5-mini" } });
fireEvent.click(screen.getByRole("button", { name: "Save model" }));
await waitFor(() => expect(mutations.update).toHaveBeenCalledWith({ id: "provider-1", model: "gpt-5-mini" }));
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["aiProviders", "list"] });
});
});
@@ -1,692 +0,0 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import {
CheckCircleIcon,
KeyIcon,
PencilIcon,
PlusIcon,
TrashIcon,
WarningCircleIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Switch } from "@reactive-resume/ui/components/switch";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type SavedProvider = RouterOutput["aiProviders"]["list"][number];
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string; defaultModel: string };
type ProviderRowProps = {
provider: SavedProvider;
};
// Provider labels are brand names and stay untranslated on purpose, so every locale shows the
// vendor's own spelling instead of a literal translation (e.g. "Google Gemini", not "谷歌双子座").
const providerOptions: AIProviderOption[] = [
{
value: "openai",
label: "OpenAI",
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openai,
defaultModel: "gpt-4.1",
},
{
value: "anthropic",
label: "Anthropic Claude",
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.anthropic,
defaultModel: "claude-3-5-sonnet-latest",
},
{
value: "gemini",
label: "Google Gemini",
keywords: ["gemini", "google"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.gemini,
defaultModel: "gemini-2.0-flash",
},
{
value: "vercel-ai-gateway",
label: "Vercel AI Gateway",
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["vercel-ai-gateway"],
defaultModel: "openai/gpt-4.1",
},
{
value: "openrouter",
label: "OpenRouter",
keywords: ["openrouter", "router"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
defaultModel: "openai/gpt-4.1",
},
{
value: "mistral",
label: "Mistral AI",
keywords: ["mistral", "magistral"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.mistral,
defaultModel: "mistral-large-latest",
},
{
value: "cohere",
label: "Cohere",
keywords: ["cohere", "command"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.cohere,
defaultModel: "command-a-03-2025",
},
{
value: "xai",
label: "xAI Grok",
keywords: ["xai", "grok"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.xai,
defaultModel: "grok-4",
},
{
value: "groq",
label: "Groq",
keywords: ["groq", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.groq,
defaultModel: "llama-3.3-70b-versatile",
},
{
value: "deepseek",
label: "DeepSeek",
keywords: ["deepseek"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.deepseek,
defaultModel: "deepseek-chat",
},
{
value: "togetherai",
label: "Together.ai",
keywords: ["together", "togetherai", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.togetherai,
defaultModel: "meta-llama/Meta-Llama-3.3-70B-Instruct-Turbo",
},
{
value: "fireworks",
label: "Fireworks",
keywords: ["fireworks", "llama", "deepseek"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.fireworks,
defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct",
},
{
value: "cerebras",
label: "Cerebras",
keywords: ["cerebras", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.cerebras,
defaultModel: "llama3.3-70b",
},
{
value: "perplexity",
label: "Perplexity",
keywords: ["perplexity", "sonar"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.perplexity,
defaultModel: "sonar-pro",
},
{
value: "ollama",
label: "Ollama Cloud",
keywords: ["ollama", "cloud"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
defaultModel: "llama3.1",
},
{
value: "openai-compatible",
label: "OpenAI-compatible",
keywords: ["compatible", "custom", "gateway"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["openai-compatible"],
defaultModel: "",
},
];
// Prefill Base URL + Model from the provider's known defaults when the provider changes.
function providerDefaults(provider: AIProvider) {
const option = providerOptions.find((entry) => entry.value === provider);
return {
baseURL: option?.defaultBaseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider] ?? "",
model: option?.defaultModel ?? "",
};
}
const emptyForm = {
label: "",
provider: "openai" as AIProvider,
apiKey: "",
...providerDefaults("openai"),
};
function statusBadge(provider: SavedProvider) {
if (provider.testStatus === "success") {
return (
<Badge variant="accent">
<Trans>Connected</Trans>
</Badge>
);
}
if (provider.testStatus === "failure") {
return (
<Badge variant="danger">
<Trans>Connection failed</Trans>
</Badge>
);
}
return (
<Badge variant="neutral">
<Trans>Not connected</Trans>
</Badge>
);
}
function providerLabel(provider: AIProvider) {
return providerOptions.find((option) => option.value === provider)?.label ?? provider;
}
// A provider test can legitimately take tens of seconds against a cold local model. Without a sense
// of time passing, a bare spinner reads as a freeze, so start narrating the wait once it gets long.
const SHOW_ELAPSED_AFTER_SECONDS = 5;
const STILL_WAITING_AFTER_SECONDS = 20;
function useElapsedSeconds(isRunning: boolean) {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (!isRunning) {
setSeconds(0);
return;
}
const startedAt = Date.now();
const interval = setInterval(() => setSeconds(Math.floor((Date.now() - startedAt) / 1000)), 1000);
return () => clearInterval(interval);
}, [isRunning]);
return seconds;
}
function testingLabel(elapsedSeconds: number, provider: string) {
if (elapsedSeconds >= STILL_WAITING_AFTER_SECONDS) return t`Still waiting for ${provider}… ${elapsedSeconds}s`;
if (elapsedSeconds >= SHOW_ELAPSED_AFTER_SECONDS) return t`Testing… ${elapsedSeconds}s`;
return null;
}
function upsertProvider(providers: SavedProvider[] | undefined, provider: SavedProvider) {
if (!providers) return [provider];
if (!providers.some((entry) => entry.id === provider.id)) return [...providers, provider];
return providers.map((entry) => (entry.id === provider.id ? provider : entry));
}
function isAiProviderConfigError(error: unknown) {
if (error instanceof ORPCError && error.code === "PRECONDITION_FAILED") return true;
if (!error || typeof error !== "object") return false;
const status = (error as { status?: unknown; code?: unknown }).status ?? (error as { code?: unknown }).code;
return status === "PRECONDITION_FAILED" || status === 412;
}
function ProviderRow({ provider }: ProviderRowProps) {
const queryClient = useQueryClient();
const [isEditingModel, setIsEditingModel] = useState(false);
const [model, setModel] = useState(provider.model);
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
const { mutate: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions());
const { mutate: updateProvider, isPending: isUpdating } = useMutation(orpc.aiProviders.update.mutationOptions());
const { mutate: deleteProvider, isPending: isDeleting } = useMutation(orpc.aiProviders.delete.mutationOptions());
const isMutating = isTesting || isUpdating || isDeleting;
const testElapsedSeconds = useElapsedSeconds(isTesting);
const testLabel = testingLabel(testElapsedSeconds, String(providerLabel(provider.provider)));
const saveModel = () => {
const nextModel = model.trim();
if (!nextModel || nextModel === provider.model) {
setIsEditingModel(false);
return;
}
updateProvider(
{ id: provider.id, model: nextModel },
{
onSuccess: () => {
setIsEditingModel(false);
void invalidate();
},
onError: (error) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Failed to update provider.` }),
}),
},
);
};
return (
<div className="grid gap-4 rounded-md border bg-card p-4 md:grid-cols-[1fr_auto]">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold">{provider.label}</h3>
{statusBadge(provider)}
{provider.enabled ? (
<Badge variant="outline">
<Trans>Enabled</Trans>
</Badge>
) : null}
</div>
<div className="grid gap-1 text-muted-foreground text-sm">
<p>
{providerLabel(provider.provider)}
{isEditingModel ? "" : ` · ${provider.model}`}
</p>
{isEditingModel ? (
<div className="flex max-w-md gap-2">
<Input
aria-label={t`Provider model`}
value={model}
disabled={isMutating}
onChange={(event) => setModel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") saveModel();
if (event.key === "Escape") setIsEditingModel(false);
}}
/>
<Button size="sm" disabled={!model.trim() || isMutating} onClick={saveModel}>
<Trans>Save model</Trans>
</Button>
<Button size="sm" variant="ghost" disabled={isMutating} onClick={() => setIsEditingModel(false)}>
<Trans>Cancel</Trans>
</Button>
</div>
) : null}
<p className="truncate">{provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider]}</p>
<p>
<Trans>Key</Trans>: {provider.apiKeyPreview}
</p>
{provider.testError ? <p className="text-danger-text">{provider.testError}</p> : null}
</div>
</div>
<div className="flex items-center gap-2 md:justify-end">
<div className="flex items-center gap-2 pe-2">
<Switch
checked={provider.enabled}
disabled={provider.testStatus !== "success" || isMutating}
onCheckedChange={(enabled) =>
updateProvider(
{ id: provider.id, enabled },
{
onSuccess: () => void invalidate(),
onError: (error) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Failed to update provider.` }),
}),
},
)
}
/>
<span className="text-muted-foreground text-sm">
<Trans>Use</Trans>
</span>
</div>
<Button
variant="secondary"
disabled={isMutating}
onClick={() =>
testProvider(
{ id: provider.id },
{
onSuccess: (response) => {
if (response.testStatus === "success") {
toast.add({ type: "success", description: t`Provider connection verified.` });
} else {
// The reason persists on the card below, so the toast only reports the outcome.
toast.add({ type: "error", description: t`Connection failed.` });
}
void invalidate();
},
onError: (error) => {
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Could not verify provider connection.` }),
});
void invalidate();
},
},
)
}
>
{isTesting ? <Spinner /> : provider.testStatus === "success" ? <CheckCircleIcon /> : <WarningCircleIcon />}
{testLabel ?? <Trans>Test</Trans>}
</Button>
<Button
size="icon"
variant="ghost"
disabled={isMutating}
onClick={() => {
setModel(provider.model);
setIsEditingModel(true);
}}
>
<PencilIcon />
<span className="sr-only">
<Trans>Edit model</Trans>
</span>
</Button>
<Button
size="icon"
variant="ghost"
disabled={isMutating}
onClick={() =>
deleteProvider(
{ id: provider.id },
{
onSuccess: () => void invalidate(),
onError: (error) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Failed to delete provider.` }),
}),
},
)
}
>
<TrashIcon />
<span className="sr-only">
<Trans>Delete provider</Trans>
</span>
</Button>
</div>
</div>
);
}
type SaveResult = { ok: boolean; message: string };
function CreateProviderForm() {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyForm);
const [result, setResult] = useState<SaveResult | null>(null);
const selectedOption = useMemo(
() => providerOptions.find((option) => option.value === form.provider),
[form.provider],
);
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
const setProviderInCache = (provider: SavedProvider) =>
queryClient.setQueryData(orpc.aiProviders.list.queryKey(), (providers: SavedProvider[] | undefined) =>
upsertProvider(providers, provider),
);
const { mutateAsync: createProvider, isPending: isCreating } = useMutation(
orpc.aiProviders.create.mutationOptions({ meta: { noInvalidate: true } }),
);
const { mutateAsync: testProvider, isPending: isTesting } = useMutation(
orpc.aiProviders.test.mutationOptions({ meta: { noInvalidate: true } }),
);
const isSaving = isCreating || isTesting;
const testElapsedSeconds = useElapsedSeconds(isTesting);
// Model/label are prefilled from provider defaults, so step 1 (Provider + API Key) is enough to save.
const model = form.model.trim();
const label = form.label.trim() || String(selectedOption?.label ?? form.provider);
const canSave = Boolean(form.apiKey.trim() && model);
const save = async () => {
setResult(null);
try {
const created = await createProvider({
label,
provider: form.provider,
model,
baseURL: form.baseURL.trim(),
apiKey: form.apiKey.trim(),
});
// Test on save: verify the connection immediately instead of leaving it to a manual step.
const tested = await testProvider({ id: created.id });
setProviderInCache(tested);
if (tested.testStatus === "success") {
setForm(emptyForm);
setResult({ ok: true, message: t`Connection verified. The provider is ready to use.` });
} else {
// ponytail: provider stays persisted on failure so it shows in the list; a re-save creates a new row.
setResult({
ok: false,
message: tested.testError ?? t`Could not verify the connection. Check the API key, model, and base URL.`,
});
}
} catch (error) {
setResult({
ok: false,
message: getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
BAD_REQUEST: t`Invalid AI provider configuration.`,
},
fallback: t`Failed to save AI provider.`,
}),
});
} finally {
void invalidate();
}
};
return (
<div className="rounded-md border bg-card p-4">
<div className="mb-4 flex items-center gap-2">
<div className="grid size-8 place-items-center rounded-md bg-primary/10 text-primary">
<PlusIcon />
</div>
<h3 className="font-semibold">
<Trans>Add Provider</Trans>
</h3>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
</Label>
<Combobox
id="ai-provider"
value={form.provider}
showClear={false}
options={providerOptions}
onValueChange={(provider) => {
if (!provider) return;
setForm((current) => ({ ...current, provider, ...providerDefaults(provider) }));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
<Input
id="ai-api-key"
type="password"
value={form.apiKey}
onChange={(event) => setForm((current) => ({ ...current, apiKey: event.target.value }))}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
data-lpignore="true"
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
<details className="rounded-md border bg-background/50 px-3 py-2 [&_summary]:cursor-pointer">
<summary className="font-medium text-muted-foreground text-sm">
<Trans>Advanced</Trans>
</summary>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ai-label">
<Trans>Label</Trans>
</Label>
<Input
id="ai-label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
placeholder={selectedOption?.label ? String(selectedOption.label) : t`Work OpenAI`}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
value={form.model}
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
placeholder="gpt-4.1"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL</Trans>
</Label>
<Input
id="ai-base-url"
type="url"
value={form.baseURL}
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
placeholder={selectedOption?.defaultBaseURL || "https://gateway.example.com/v1"}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
</div>
</details>
</div>
{result ? (
<div
className={cn(
"mt-4 flex items-start gap-2 rounded-md border p-3 text-sm",
result.ok
? "border-accent/40 bg-accent-soft text-accent-text"
: "border-danger/40 bg-danger-soft text-danger-text",
)}
>
{result.ok ? (
<CheckCircleIcon className="mt-0.5 shrink-0 text-accent-text" />
) : (
<WarningCircleIcon className="mt-0.5 shrink-0 text-danger-text" />
)}
<span>{result.message}</span>
</div>
) : null}
<div className="mt-4 flex justify-end">
<Button disabled={!canSave || isSaving} onClick={() => void save()}>
{isSaving ? <Spinner /> : <KeyIcon />}
{isTesting ? (
(testingLabel(testElapsedSeconds, String(selectedOption?.label ?? form.provider)) ?? (
<Trans>Testing…</Trans>
))
) : (
<Trans>Save & Test Provider</Trans>
)}
</Button>
</div>
</div>
);
}
export function AISettingsSection() {
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
const { hasUsableProvider } = useHasUsableAiProvider();
const isConfigError = isAiProviderConfigError(error);
return (
<section className="grid gap-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="font-semibold text-lg">
<Trans>AI Providers</Trans>
</h2>
<p className="text-muted-foreground text-sm">
<Trans>API keys are encrypted on the server and never shown again after saving.</Trans>
</p>
</div>
<p className="flex items-center gap-2 text-sm">
{hasUsableProvider ? (
<CheckCircleIcon className="text-accent-text" />
) : (
<XCircleIcon className="text-danger-text" />
)}
<span className={cn(hasUsableProvider ? "text-accent-text" : "text-muted-foreground")}>
{hasUsableProvider ? <Trans>Agent ready</Trans> : <Trans>No tested provider</Trans>}
</span>
</p>
</div>
{error ? (
<div
className={cn(
"rounded-md border p-4 text-sm",
isConfigError
? "border-warn/40 bg-warn-soft text-warn-text"
: "border-danger/40 bg-danger-soft text-danger-text",
)}
>
{isConfigError ? (
<Trans>AI provider management is unavailable until REDIS_URL and ENCRYPTION_SECRET are configured.</Trans>
) : (
<Trans>AI provider management is unavailable. Please try again.</Trans>
)}
</div>
) : null}
{error ? null : <CreateProviderForm />}
<div className="grid gap-3">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Spinner />
<Trans>Loading providers…</Trans>
</div>
) : null}
{providers?.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground text-sm">
<Trans>Add and test a provider before starting an agent thread.</Trans>
</div>
) : null}
{providers?.map((provider) => (
<ProviderRow key={provider.id} provider={provider} />
))}
</div>
</section>
);
}
@@ -1,9 +0,0 @@
import { AISettingsSection } from "./components/ai-section";
export function IntegrationsSettingsPage() {
return (
<div className="grid max-w-4xl gap-8">
<AISettingsSection />
</div>
);
}
@@ -1,128 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { useConfirm } from "@/hooks/use-confirm";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
const CONFIRMATION_TEXT = "delete";
export function AccountSettingsPage() {
const confirm = useConfirm();
const navigate = useNavigate();
const [confirmationText, setConfirmationText] = useState("");
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
const { mutate: exportData, isPending: isExporting } = useMutation(
orpc.auth.exportData.mutationOptions({
onSuccess: (data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
downloadWithAnchor(blob, generateFilename("reactive-resume-export", "json"));
toast.add({ type: "success", description: t`Your data has been exported.` });
},
onError: (error) => {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when data export fails",
message: "Failed to export your data. Please try again.",
}),
),
});
},
}),
);
const handleDeleteAccount = async () => {
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
description: t`This action cannot be undone. All your data will be permanently deleted.`,
confirmText: t({
comment: "Account deletion confirmation dialog confirm action in account settings",
message: "Confirm",
}),
cancelText: t({
comment: "Account deletion confirmation dialog cancel action in account settings",
message: "Cancel",
}),
});
if (!confirmed) return;
const toastId = toast.add({ type: "loading", description: t`Deleting your account...` });
deleteAccount(undefined, {
onSuccess: async () => {
toast.add({ type: "success", description: t`Your account has been deleted.`, id: toastId });
await authClient.signOut();
void navigate({ to: "/" });
},
onError: (error) => {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when account deletion fails",
message: "Failed to delete your account. Please try again.",
}),
),
id: toastId,
});
},
});
};
return (
<div className="grid max-w-xl gap-6">
<div className="grid gap-3">
<p className="leading-relaxed">
<Trans>Download a copy of all your data, including your profile and every resume, as a JSON file.</Trans>
</p>
<Button
variant="secondary"
className="justify-self-start"
onClick={() => exportData(undefined)}
disabled={isExporting}
>
<DownloadSimpleIcon />
<Trans>Export my data</Trans>
</Button>
</div>
<hr className="border-border" />
<p className="leading-relaxed">
<Trans>To delete your account, type the confirmation text below, then click the button.</Trans>
</p>
<Input
type="text"
value={confirmationText}
onChange={(e) => setConfirmationText(e.target.value)}
placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`}
/>
<Button
variant="danger"
className="justify-self-end"
onClick={handleDeleteAccount}
disabled={confirmationText !== CONFIRMATION_TEXT}
>
<TrashSimpleIcon />
<Trans>Delete Account</Trans>
</Button>
</div>
);
}
@@ -1,69 +0,0 @@
// @vitest-environment happy-dom
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
const listApiKeys = vi.hoisted(() => vi.fn());
vi.mock("@/libs/auth/client", () => ({
authClient: { apiKey: { list: listApiKeys } },
}));
i18n.loadAndActivate({ locale: "en", messages: {} });
const { ApiKeysSettingsPage } = await import("./api-keys");
describe("ApiKeysSettingsPage", () => {
it("shows active keys that never expire", async () => {
listApiKeys.mockResolvedValue({
data: {
apiKeys: [
{
id: "key-1",
configId: "default",
name: "no-expiry-test",
start: "rr_no_expiry",
prefix: null,
referenceId: "user-1",
refillInterval: null,
refillAmount: null,
lastRefillAt: null,
enabled: true,
rateLimitEnabled: false,
rateLimitTimeWindow: null,
rateLimitMax: null,
requestCount: 0,
remaining: null,
lastRequest: null,
expiresAt: null,
createdAt: new Date("2026-08-16T10:00:00Z"),
updatedAt: new Date("2026-08-16T10:00:00Z"),
metadata: null,
permissions: null,
},
],
total: 1,
limit: undefined,
offset: undefined,
},
error: null,
});
render(
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<ConfirmDialogProvider>
<ApiKeysSettingsPage />
</ConfirmDialogProvider>
</QueryClientProvider>
</I18nProvider>,
);
await waitFor(() => expect(screen.getByText("rr_no_expiry...")).toBeInTheDocument());
expect(screen.getByText("Never expires")).toBeInTheDocument();
});
});
@@ -1,135 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { BookOpenIcon, KeyIcon, LinkSimpleIcon, PlusIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { toast } from "@reactive-resume/ui/components/toast";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
export function ApiKeysSettingsPage() {
const confirm = useConfirm();
const queryClient = useQueryClient();
const openDialog = useDialogStore((state) => state.openDialog);
const { data: apiKeys = [] } = useQuery({
queryKey: ["auth", "api-keys"],
queryFn: () => authClient.apiKey.list(),
select: ({ data }) => {
if (!data) return [];
return data.apiKeys
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.filter((key) => !key.expiresAt || key.expiresAt.getTime() > Date.now());
},
});
const onDelete = async (id: string) => {
const confirmation = await confirm(t`Are you sure you want to delete this API key?`, {
description: t`Once deleted, the key can no longer access your data. This cannot be undone.`,
confirmText: t({
comment: "API key deletion confirmation dialog confirm action in settings",
message: "Delete",
}),
cancelText: t({
comment: "API key deletion confirmation dialog cancel action in settings",
message: "Cancel",
}),
});
if (!confirmation) return;
const toastId = toast.add({ type: "loading", description: t`Deleting your API key...` });
const { error } = await authClient.apiKey.delete({ keyId: id });
if (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when deleting an API key fails",
message: "Failed to delete the API key. Please try again.",
}),
),
id: toastId,
});
return;
}
toast.add({ type: "success", description: t`The API key has been deleted.`, id: toastId });
void queryClient.invalidateQueries({ queryKey: ["auth", "api-keys"] });
};
return (
<div className="grid max-w-xl gap-6">
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
<div className="rounded-md bg-primary/10 p-2.5">
<BookOpenIcon className="text-primary" size={24} />
</div>
<div className="flex-1 space-y-2">
<h3 className="font-semibold">
<Trans>How do I use the API?</Trans>
</h3>
<p className="text-muted-foreground leading-relaxed">
<Trans>
The API documentation shows how to connect Reactive Resume to your own applications. It covers the
endpoints, the request format, and authentication.
</Trans>
</p>
<Button
variant="link"
nativeButton={false}
render={
<a href="https://docs.rxresu.me/api-reference" target="_blank" rel="noopener noreferrer">
<LinkSimpleIcon />
<Trans>API Reference</Trans>
</a>
}
/>
</div>
</div>
<Separator />
<div>
<Button
variant="secondary"
className="h-auto w-full py-3"
onClick={() => openDialog("api-key.create", undefined)}
>
<PlusIcon />
<Trans>Create a new API key</Trans>
</Button>
{apiKeys.map((key) => (
<div key={key.id} className="flex items-center gap-x-4 py-4">
<KeyIcon />
<div className="flex-1 space-y-1">
<p className="font-mono text-xs">{key.start}...</p>
<div className="text-muted-foreground text-xs">
{key.expiresAt ? (
<Trans>Expires on {key.expiresAt.toLocaleDateString()}</Trans>
) : (
<Trans>Never expires</Trans>
)}
</div>
</div>
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
<TrashSimpleIcon />
</Button>
</div>
))}
</div>
</div>
);
}
@@ -1,38 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { LocaleCombobox } from "@/features/locale/combobox";
import { ThemeCombobox } from "@/features/theme/combobox";
export function PreferencesSettingsPage() {
return (
<div className="grid max-w-xl gap-6">
<div className="grid gap-1.5">
<Label className="mb-0.5">
<Trans>Theme</Trans>
</Label>
<ThemeCombobox />
</div>
<div className="grid gap-1.5">
<Label className="mb-0.5">
<Trans>Language</Trans>
</Label>
<LocaleCombobox />
<Button
size="sm"
variant="link"
nativeButton={false}
className="h-5 justify-start text-muted-foreground text-xs"
render={
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener noreferrer">
<Trans>Help translate the app to your language</Trans>
<ArrowRightIcon className="size-3" />
</a>
}
/>
</div>
</div>
);
}
@@ -1,274 +0,0 @@
import type { AuthSession } from "@reactive-resume/auth/types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckIcon, WarningIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useRouteContext, useRouter } from "@tanstack/react-router";
import { AnimatePresence, m } from "motion/react";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { toast } from "@reactive-resume/ui/components/toast";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { useAppForm } from "@/libs/tanstack-form";
const formSchema = z.object({
name: z.string().trim().min(1).max(64),
username: z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9._-]+$/, {
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
}),
email: z.email().trim(),
});
type Props = {
session: AuthSession;
};
export function ProfileSettingsPage({ session }: Props) {
const router = useRouter();
const context = useRouteContext({ strict: false });
const smtpEnabled = context.flags?.smtpEnabled ?? false;
const form = useAppForm({
defaultValues: {
name: session.user.name,
username: session.user.username,
email: session.user.email,
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
const { error } = await authClient.updateUser({
name: value.name,
username: value.username,
displayUsername: value.username,
});
if (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when updating profile details fails",
message: "Failed to update your profile. Please try again.",
}),
),
});
return;
}
toast.add({ type: "success", description: t`Your profile has been updated.` });
form.reset({ name: value.name, username: value.username, email: session.user.email });
void router.invalidate();
if (value.email !== session.user.email) {
const { error } = await authClient.changeEmail({
newEmail: value.email,
callbackURL: "/dashboard/settings/profile",
});
if (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when requesting email change confirmation fails",
message: "Failed to request email change. Please try again.",
}),
),
});
return;
}
toast.add({
type: "success",
description: t`We sent a confirmation link to your new email address. Open it to confirm the change.`,
});
form.reset({ name: value.name, username: value.username, email: session.user.email });
void router.invalidate();
}
},
});
const onCancel = () => {
form.reset();
};
const isDirty = useStore(form.store, (s) => s.isDirty);
const handleResendVerificationEmail = async () => {
const toastId = toast.add({ type: "loading", description: t`Resending verification email...` });
const { error } = await authClient.sendVerificationEmail({
email: session.user.email,
callbackURL: "/dashboard/settings/profile",
});
if (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(
error,
t({
comment: "Fallback toast when resending account verification email fails",
message: "Failed to resend verification email. Please try again.",
}),
),
id: toastId,
});
return;
}
toast.add({
type: "success",
description: t`We sent a new verification link to your email address. Open it to verify your account.`,
id: toastId,
});
void router.invalidate();
};
return (
<form
className="grid max-w-xl gap-6"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="name">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
min={3}
max={64}
autoComplete="name"
placeholder={t({
comment: "Example full name placeholder on profile settings form",
message: "John Doe",
})}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="username">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<FormControl
render={
<Input
min={3}
max={64}
autoComplete="username"
placeholder={t({
comment: "Example username placeholder on profile settings form",
message: "john.doe",
})}
className="lowercase"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl
render={
<Input
type="email"
autoComplete="email"
placeholder="john.doe@example.com"
className="lowercase"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
{session.user.emailVerified === true ? (
<p className="flex items-center gap-x-1.5 text-accent-text text-xs">
<CheckIcon />
<Trans>Verified</Trans>
</p>
) : smtpEnabled ? (
<p className="flex items-center gap-x-1.5 text-warn-text text-xs">
<WarningIcon className="size-3.5" />
<Trans>Unverified</Trans>
<span>|</span>
<Button
variant="link"
className="h-auto gap-x-1.5 p-0! text-inherit text-xs"
onClick={handleResendVerificationEmail}
>
<Trans>Resend verification email</Trans>
</Button>
</p>
) : (
<p className="text-muted-foreground text-xs">
<Trans>Email delivery isn't configured on this instance, so verification is disabled.</Trans>
</p>
)}
</FormItem>
)}
</form.Field>
<AnimatePresence initial={false} mode="popLayout">
{isDirty && (
<m.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: EASE_OUT_STRONG }}
className="flex items-center gap-x-4 justify-self-end"
>
<Button type="reset" variant="ghost" onClick={onCancel}>
<Trans comment="Profile settings form action to discard unsaved edits">Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Save Changes</Trans>
</Button>
</m.div>
)}
</AnimatePresence>
</form>
);
}
@@ -0,0 +1,95 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { Theme } from "@/libs/theme";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useId } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { cn } from "@reactive-resume/utils/style";
import { LocaleCombobox } from "@/features/locale/combobox";
import { useTheme } from "@/features/theme/provider";
import { themeMap } from "@/libs/theme";
import { SettingsRow, SettingsSection } from "./section";
const THEMES: Array<{ value: Theme; icon: IconName }> = [
{ value: "light", icon: "light_mode" },
{ value: "dark", icon: "dark_mode" },
{ value: "system", icon: "contrast" },
];
export function PreferencesSettings() {
const languageId = useId();
return (
<>
<SettingsSection title={<Trans>Appearance</Trans>}>
<ThemeTiles />
<p className="text-ink-3 text-xs">
<Trans>Resumes always render on white paper, whatever the theme.</Trans>
</p>
</SettingsSection>
<SettingsSection title={<Trans>Language</Trans>}>
<div className="grid max-w-80 gap-1.5">
<label htmlFor={languageId} className="sr-only">
<Trans>Interface language</Trans>
</label>
<LocaleCombobox id={languageId} />
</div>
<p className="text-ink-3 text-xs">
<Trans>Interface only. Each resume sets its own language in Design.</Trans>{" "}
<a
href="https://crowdin.com/project/reactive-resume"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
<Trans>Help translate</Trans>
</a>
</p>
</SettingsSection>
<SettingsSection title={<Trans>Motion</Trans>}>
<SettingsRow
title={<Trans>Follows your system's reduced-motion setting.</Trans>}
description={<Trans>Nothing in the app animates on its own.</Trans>}
/>
</SettingsSection>
</>
);
}
/** Light, Dark and System as three tiles; the choice applies at once. */
function ThemeTiles() {
const { i18n } = useLingui();
const { theme, setTheme } = useTheme();
return (
<fieldset className="grid grid-cols-3 gap-2.5">
<legend className="sr-only">{t`Theme`}</legend>
{THEMES.map((option) => {
const checked = theme === option.value;
return (
<label
key={option.value}
className={cn(
"flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border border-line-2 bg-surface font-medium text-sm transition-[border-color,box-shadow] duration-quick hover:bg-hover has-focus-visible:outline-2 has-focus-visible:outline-accent",
checked && "border-accent shadow-[0_0_0_3px_var(--accent-soft)]",
)}
>
<input
type="radio"
name="theme"
value={option.value}
className="sr-only"
checked={checked}
onChange={() => setTheme(option.value)}
/>
<Icon name={option.icon} size={22} />
{i18n.t(themeMap[option.value])}
</label>
);
})}
</fieldset>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { ReactNode } from "react";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { Link, useRouteContext } from "@tanstack/react-router";
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
import { Icon } from "@reactive-resume/ui/components/icon";
import { getInitials } from "@reactive-resume/utils/string";
import { useTheme } from "@/features/theme/provider";
import { orpc } from "@/libs/orpc/client";
import { themeMap } from "@/libs/theme";
import { SignOutButton } from "./account/page";
type RowProps = { icon: IconName; label: ReactNode; value?: ReactNode };
function Row({ icon, label, value }: RowProps) {
return (
<>
<Icon name={icon} size={22} className="text-ink-2" />
<span className="flex-1">{label}</span>
{value && <span className="truncate text-ink-3 text-sm">{value}</span>}
<Icon name="chevron_right" size={22} className="text-ink-3" />
</>
);
}
const rowClass = "flex h-14 items-center gap-3 px-3.5 font-medium text-base";
/** Phones: the Account tab is the settings root, three rows showing their current values. */
export function SettingsRoot() {
const { i18n } = useLingui();
const { session } = useRouteContext({ from: "/dashboard" });
const { theme } = useTheme();
const { data: providers } = useQuery(orpc.aiProviders.list.queryOptions());
const provider = providers?.find((entry) => entry.enabled && entry.testStatus === "success");
return (
<div className="grid gap-4">
<div className="flex items-center gap-3">
<Avatar className="size-12">
<AvatarImage src={session.user.image ?? undefined} alt="" />
<AvatarFallback className="bg-accent-soft font-semibold text-accent-text">
{getInitials(session.user.name)}
</AvatarFallback>
</Avatar>
<span className="grid min-w-0">
<b className="truncate font-semibold text-lg">{session.user.name}</b>
<span className="truncate text-ink-3 text-sm">{session.user.email}</span>
</span>
</div>
<nav className="divide-y divide-line overflow-hidden rounded-[14px] border border-line bg-surface">
<Link to="/dashboard/settings/account" className={rowClass}>
<Row icon="account_circle" label={<Trans>Account</Trans>} />
</Link>
<Link to="/dashboard/settings/preferences" className={rowClass}>
<Row icon="tune" label={<Trans>Preferences</Trans>} value={i18n.t(themeMap[theme])} />
</Link>
<Link to="/dashboard/settings/ai" className={rowClass}>
<Row icon="hub" label={<Trans>AI & developer</Trans>} value={provider?.label ?? <Trans>Not set up</Trans>} />
</Link>
</nav>
<a
href="https://docs.rxresu.me"
target="_blank"
rel="noopener noreferrer"
className="flex h-12 items-center rounded-[14px] border border-line bg-surface px-3.5 font-medium"
>
<Trans>Help & docs</Trans>
</a>
<SignOutButton />
</div>
);
}
@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
import { cn } from "@reactive-resume/utils/style";
type SettingsSectionProps = {
title: ReactNode;
/** A short line under the title. */
description?: ReactNode;
/** Sits at the right of the title, e.g. "New key". */
action?: ReactNode;
children: ReactNode;
className?: string;
};
/** A settings group: a title and its rows, divided from the group above by a rule. No card around it. */
export function SettingsSection({ title, description, action, children, className }: SettingsSectionProps) {
return (
<section className={cn("grid gap-3 border-line border-t pt-6 first:border-t-0 first:pt-0", className)}>
<div className="flex items-center justify-between gap-3">
<div className="grid gap-0.5">
<h2 className="font-semibold text-[17px]">{title}</h2>
{description && <p className="text-ink-3 text-sm">{description}</p>}
</div>
{action}
</div>
{children}
</section>
);
}
type SettingsRowProps = {
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
};
/** One setting: what it is on the left, its control on the right. */
export function SettingsRow({ title, description, children }: SettingsRowProps) {
return (
<div className="flex flex-wrap items-center gap-3 py-2">
<div className="grid min-w-0 flex-1 gap-0.5">
<span className="font-medium text-sm">{title}</span>
{description && <span className="text-[13px] text-ink-3">{description}</span>}
</div>
{children}
</div>
);
}
-28
View File
@@ -1,28 +0,0 @@
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { useLingui } from "@lingui/react";
import { useRouter } from "@tanstack/react-router";
import { Combobox } from "@/components/ui/combobox";
import { isTheme, themeMap } from "@/libs/theme";
import { useTheme } from "./provider";
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export function ThemeCombobox(props: Props) {
const router = useRouter();
const { i18n } = useLingui();
const { theme, setTheme } = useTheme();
const options = Object.entries(themeMap).map(([value, label]) => ({
value,
label: i18n.t(label),
keywords: [i18n.t(label)],
}));
const onThemeChange = (value: string | null) => {
if (!value || !isTheme(value)) return;
setTheme(value);
void router.invalidate();
};
return <Combobox {...props} showClear={false} options={options} defaultValue={theme} onValueChange={onThemeChange} />;
}
+1 -1
View File
@@ -73,7 +73,7 @@ export function UserDropdownMenu({ children }: Props) {
<DropdownMenuTrigger render={children({ session: session as AuthSession })} />
<DropdownMenuContent align="start" side="top">
<DropdownMenuItem onClick={() => void router.navigate({ to: "/dashboard/settings/profile" })}>
<DropdownMenuItem onClick={() => void router.navigate({ to: "/dashboard/settings" })}>
<Icon name="settings" />
<Trans>Settings</Trans>
</DropdownMenuItem>
+42 -2
View File
@@ -39,7 +39,9 @@ import { Route as BuilderResumeIdIndexRouteImport } from "./routes/builder/$resu
import { Route as BuilderLetterCoverLetterIdRouteImport } from "./routes/builder/letter/$coverLetterId";
import { Route as DashboardApplicationsIndexRouteImport } from "./routes/dashboard/applications/index";
import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index";
import { Route as DashboardSettingsIndexRouteImport } from "./routes/dashboard/settings/index";
import { Route as DashboardSettingsAccountRouteImport } from "./routes/dashboard/settings/account";
import { Route as DashboardSettingsAiRouteImport } from "./routes/dashboard/settings/ai";
import { Route as DashboardSettingsApiKeysRouteImport } from "./routes/dashboard/settings/api-keys";
import { Route as DashboardSettingsIntegrationsRouteRouteImport } from "./routes/dashboard/settings/integrations/route";
import { Route as DashboardSettingsJobSearchRouteImport } from "./routes/dashboard/settings/job-search";
@@ -198,12 +200,22 @@ const DashboardResumesIndexRoute = DashboardResumesIndexRouteImport.update({
path: "/resumes/",
getParentRoute: () => DashboardRouteRoute,
} as any);
const DashboardSettingsIndexRoute = DashboardSettingsIndexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsAccountRoute =
DashboardSettingsAccountRouteImport.update({
id: "/account",
path: "/account",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsAiRoute = DashboardSettingsAiRouteImport.update({
id: "/ai",
path: "/ai",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsApiKeysRoute =
DashboardSettingsApiKeysRouteImport.update({
id: "/api-keys",
@@ -270,6 +282,7 @@ export interface FileRoutesByFullPath {
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/builder/letter/$coverLetterId": typeof BuilderLetterCoverLetterIdRoute;
"/dashboard/settings/account": typeof DashboardSettingsAccountRoute;
"/dashboard/settings/ai": typeof DashboardSettingsAiRoute;
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
@@ -277,10 +290,10 @@ export interface FileRoutesByFullPath {
"/builder/$resumeId/": typeof BuilderResumeIdIndexRoute;
"/dashboard/applications/": typeof DashboardApplicationsIndexRoute;
"/dashboard/resumes/": typeof DashboardResumesIndexRoute;
"/dashboard/settings/": typeof DashboardSettingsIndexRoute;
"/dashboard/settings/authentication/": typeof DashboardSettingsAuthenticationIndexRoute;
}
export interface FileRoutesByTo {
"/dashboard/settings": typeof DashboardSettingsRouteRouteWithChildren;
"/$username/$slug": typeof UsernameSlugRoute;
"/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
@@ -304,6 +317,7 @@ export interface FileRoutesByTo {
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/builder/letter/$coverLetterId": typeof BuilderLetterCoverLetterIdRoute;
"/dashboard/settings/account": typeof DashboardSettingsAccountRoute;
"/dashboard/settings/ai": typeof DashboardSettingsAiRoute;
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
@@ -311,6 +325,7 @@ export interface FileRoutesByTo {
"/builder/$resumeId": typeof BuilderResumeIdIndexRoute;
"/dashboard/applications": typeof DashboardApplicationsIndexRoute;
"/dashboard/resumes": typeof DashboardResumesIndexRoute;
"/dashboard/settings": typeof DashboardSettingsIndexRoute;
"/dashboard/settings/authentication": typeof DashboardSettingsAuthenticationIndexRoute;
}
export interface FileRoutesById {
@@ -344,6 +359,7 @@ export interface FileRoutesById {
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/builder/letter/$coverLetterId": typeof BuilderLetterCoverLetterIdRoute;
"/dashboard/settings/account": typeof DashboardSettingsAccountRoute;
"/dashboard/settings/ai": typeof DashboardSettingsAiRoute;
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
"/dashboard/settings/preferences": typeof DashboardSettingsPreferencesRoute;
@@ -351,6 +367,7 @@ export interface FileRoutesById {
"/builder/$resumeId/": typeof BuilderResumeIdIndexRoute;
"/dashboard/applications/": typeof DashboardApplicationsIndexRoute;
"/dashboard/resumes/": typeof DashboardResumesIndexRoute;
"/dashboard/settings/": typeof DashboardSettingsIndexRoute;
"/dashboard/settings/authentication/": typeof DashboardSettingsAuthenticationIndexRoute;
}
export interface FileRouteTypes {
@@ -384,6 +401,7 @@ export interface FileRouteTypes {
| "/dashboard/settings/integrations"
| "/builder/letter/$coverLetterId"
| "/dashboard/settings/account"
| "/dashboard/settings/ai"
| "/dashboard/settings/api-keys"
| "/dashboard/settings/job-search"
| "/dashboard/settings/preferences"
@@ -391,10 +409,10 @@ export interface FileRouteTypes {
| "/builder/$resumeId/"
| "/dashboard/applications/"
| "/dashboard/resumes/"
| "/dashboard/settings/"
| "/dashboard/settings/authentication/";
fileRoutesByTo: FileRoutesByTo;
to:
| "/dashboard/settings"
| "/$username/$slug"
| "/ats-checker"
| "/agent/$threadId"
@@ -418,6 +436,7 @@ export interface FileRouteTypes {
| "/dashboard/settings/integrations"
| "/builder/letter/$coverLetterId"
| "/dashboard/settings/account"
| "/dashboard/settings/ai"
| "/dashboard/settings/api-keys"
| "/dashboard/settings/job-search"
| "/dashboard/settings/preferences"
@@ -425,6 +444,7 @@ export interface FileRouteTypes {
| "/builder/$resumeId"
| "/dashboard/applications"
| "/dashboard/resumes"
| "/dashboard/settings"
| "/dashboard/settings/authentication";
id:
| "__root__"
@@ -457,6 +477,7 @@ export interface FileRouteTypes {
| "/dashboard/settings/integrations"
| "/builder/letter/$coverLetterId"
| "/dashboard/settings/account"
| "/dashboard/settings/ai"
| "/dashboard/settings/api-keys"
| "/dashboard/settings/job-search"
| "/dashboard/settings/preferences"
@@ -464,6 +485,7 @@ export interface FileRouteTypes {
| "/builder/$resumeId/"
| "/dashboard/applications/"
| "/dashboard/resumes/"
| "/dashboard/settings/"
| "/dashboard/settings/authentication/";
fileRoutesById: FileRoutesById;
}
@@ -690,6 +712,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardResumesIndexRouteImport;
parentRoute: typeof DashboardRouteRoute;
};
"/dashboard/settings/": {
id: "/dashboard/settings/";
path: "/";
fullPath: "/dashboard/settings/";
preLoaderRoute: typeof DashboardSettingsIndexRouteImport;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/account": {
id: "/dashboard/settings/account";
path: "/account";
@@ -697,6 +726,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardSettingsAccountRouteImport;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/ai": {
id: "/dashboard/settings/ai";
path: "/ai";
fullPath: "/dashboard/settings/ai";
preLoaderRoute: typeof DashboardSettingsAiRouteImport;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/api-keys": {
id: "/dashboard/settings/api-keys";
path: "/api-keys";
@@ -805,10 +841,12 @@ const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
interface DashboardSettingsRouteRouteChildren {
DashboardSettingsIntegrationsRouteRoute: typeof DashboardSettingsIntegrationsRouteRoute;
DashboardSettingsAccountRoute: typeof DashboardSettingsAccountRoute;
DashboardSettingsAiRoute: typeof DashboardSettingsAiRoute;
DashboardSettingsApiKeysRoute: typeof DashboardSettingsApiKeysRoute;
DashboardSettingsJobSearchRoute: typeof DashboardSettingsJobSearchRoute;
DashboardSettingsPreferencesRoute: typeof DashboardSettingsPreferencesRoute;
DashboardSettingsProfileRoute: typeof DashboardSettingsProfileRoute;
DashboardSettingsIndexRoute: typeof DashboardSettingsIndexRoute;
DashboardSettingsAuthenticationIndexRoute: typeof DashboardSettingsAuthenticationIndexRoute;
}
@@ -817,10 +855,12 @@ const DashboardSettingsRouteRouteChildren: DashboardSettingsRouteRouteChildren =
DashboardSettingsIntegrationsRouteRoute:
DashboardSettingsIntegrationsRouteRoute,
DashboardSettingsAccountRoute: DashboardSettingsAccountRoute,
DashboardSettingsAiRoute: DashboardSettingsAiRoute,
DashboardSettingsApiKeysRoute: DashboardSettingsApiKeysRoute,
DashboardSettingsJobSearchRoute: DashboardSettingsJobSearchRoute,
DashboardSettingsPreferencesRoute: DashboardSettingsPreferencesRoute,
DashboardSettingsProfileRoute: DashboardSettingsProfileRoute,
DashboardSettingsIndexRoute: DashboardSettingsIndexRoute,
DashboardSettingsAuthenticationIndexRoute:
DashboardSettingsAuthenticationIndexRoute,
};
@@ -450,7 +450,7 @@ function EmptyBody({ letter, application, disabled, onWrite }: EmptyBodyProps) {
{canDraft && !hasUsableProvider && (
<p className="text-ink-3 text-xs">
<Trans>Drafting needs an AI provider.</Trans>{" "}
<Link to="/dashboard/settings/integrations" className={buttonVariants({ variant: "link", size: "sm" })}>
<Link to="/dashboard/settings/ai" className={buttonVariants({ variant: "link", size: "sm" })}>
<Trans>Open AI settings</Trans>
</Link>
</p>
@@ -55,7 +55,7 @@ function useNavItems() {
/** Whether the item is the current page: Documents is exact, the others match their section. */
function useIsCurrent() {
const matchRoute = useMatchRoute();
return (to: NavItem["to"]) => Boolean(matchRoute({ to, fuzzy: to !== "/dashboard" }));
return (to: NavItem["to"] | "/dashboard/settings") => Boolean(matchRoute({ to, fuzzy: to !== "/dashboard" }));
}
/**
@@ -325,13 +325,14 @@ function MobileTabs() {
<Trans>New</Trans>
</span>
</button>
<UserDropdownMenu>
{() => (
<button type="button" className="flex min-h-[52px] flex-col items-center justify-center gap-0.5">
{tab("account_circle", t`Account`, false)}
</button>
)}
</UserDropdownMenu>
{/* The Account tab is the settings root (README §6.9, B). */}
<Link
to="/dashboard/settings"
aria-current={isCurrent("/dashboard/settings") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5"
>
{tab("account_circle", t`Account`, isCurrent("/dashboard/settings"))}
</Link>
</nav>
);
}
@@ -1,28 +0,0 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ListIcon } from "@phosphor-icons/react";
import { DashboardHeader } from "./header";
const renderHeader = (props: Partial<React.ComponentProps<typeof DashboardHeader>> = {}) =>
render(<DashboardHeader title="Resumes" icon={ListIcon} {...props} />);
describe("DashboardHeader", () => {
it("renders the title as an h1", () => {
renderHeader();
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.textContent).toBe("Resumes");
});
it("renders the supplied icon as an SVG", () => {
const { container } = renderHeader();
expect(container.querySelector("svg")).not.toBeNull();
});
it("merges custom className into the wrapper", () => {
const { container } = renderHeader({ className: "custom-class" });
const wrapper = container.querySelector(".custom-class");
expect(wrapper).not.toBeNull();
});
});
@@ -1,22 +0,0 @@
import type { Icon as IconType } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { cn } from "@reactive-resume/utils/style";
type Props = {
title: string;
icon: IconType;
className?: string;
actions?: ReactNode;
};
export function DashboardHeader({ title, icon: IconComponent, className, actions }: Props) {
return (
<div className={cn("relative flex items-center gap-x-2.5", className)}>
<div className="flex flex-1 items-center gap-x-2.5">
<IconComponent weight="light" className="size-5" />
<h1 className="font-medium text-xl tracking-tight">{title}</h1>
</div>
{actions ? <div className="flex items-center gap-x-2">{actions}</div> : null}
</div>
);
}
@@ -1,22 +1,6 @@
import { t } from "@lingui/core/macro";
import { UserGearIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { Separator } from "@reactive-resume/ui/components/separator";
import { AccountSettingsPage } from "@/features/settings/pages/account";
import { DashboardHeader } from "../-components/header";
import { AccountSettings } from "@/features/settings/account/page";
export const Route = createFileRoute("/dashboard/settings/account")({
component: RouteComponent,
component: AccountSettings,
});
function RouteComponent() {
return (
<div className="space-y-4">
<DashboardHeader icon={UserGearIcon} title={t`Account`} />
<Separator />
<AccountSettingsPage />
</div>
);
}
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { AiDeveloperSettings } from "@/features/settings/ai/page";
export const Route = createFileRoute("/dashboard/settings/ai")({
component: AiDeveloperSettings,
});
@@ -1,22 +1,8 @@
import { t } from "@lingui/core/macro";
import { KeyIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { Separator } from "@reactive-resume/ui/components/separator";
import { ApiKeysSettingsPage } from "@/features/settings/pages/api-keys";
import { DashboardHeader } from "../-components/header";
import { createFileRoute, redirect } from "@tanstack/react-router";
// Settings became three pages in 6.0; the old address redirects (through 6.0.x).
export const Route = createFileRoute("/dashboard/settings/api-keys")({
component: RouteComponent,
beforeLoad: () => {
throw redirect({ to: "/dashboard/settings/ai", replace: true });
},
});
function RouteComponent() {
return (
<div className="space-y-4">
<DashboardHeader icon={KeyIcon} title={t`API Keys`} />
<Separator />
<ApiKeysSettingsPage />
</div>
);
}
@@ -1,22 +1,8 @@
import { t } from "@lingui/core/macro";
import { ShieldCheckIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { Separator } from "@reactive-resume/ui/components/separator";
import { AuthenticationSettingsPage } from "@/features/settings/authentication";
import { DashboardHeader } from "../../-components/header";
import { createFileRoute, redirect } from "@tanstack/react-router";
// Settings became three pages in 6.0; the old address redirects (through 6.0.x).
export const Route = createFileRoute("/dashboard/settings/authentication/")({
component: RouteComponent,
beforeLoad: () => {
throw redirect({ to: "/dashboard/settings/account", replace: true });
},
});
function RouteComponent() {
return (
<div className="space-y-4">
<DashboardHeader icon={ShieldCheckIcon} title={t`Authentication`} />
<Separator />
<AuthenticationSettingsPage />
</div>
);
}
@@ -0,0 +1,11 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { SettingsRoot } from "@/features/settings/root";
// Phones (below 640px) get the three-row root; wider screens open Account, with the pages beside it.
export const Route = createFileRoute("/dashboard/settings/")({
beforeLoad: () => {
if (window.matchMedia("(min-width: 640px)").matches)
throw redirect({ to: "/dashboard/settings/account", replace: true });
},
component: SettingsRoot,
});
@@ -1,22 +1,8 @@
import { t } from "@lingui/core/macro";
import { BrainIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { Separator } from "@reactive-resume/ui/components/separator";
import { IntegrationsSettingsPage } from "@/features/settings/integrations";
import { DashboardHeader } from "../../-components/header";
import { createFileRoute, redirect } from "@tanstack/react-router";
// Settings became three pages in 6.0; the old address redirects (through 6.0.x).
export const Route = createFileRoute("/dashboard/settings/integrations")({
component: RouteComponent,
beforeLoad: () => {
throw redirect({ to: "/dashboard/settings/ai", replace: true });
},
});
function RouteComponent() {
return (
<div className="space-y-4">
<DashboardHeader icon={BrainIcon} title={t`Integrations`} />
<Separator />
<IntegrationsSettingsPage />
</div>
);
}
@@ -1,7 +1,8 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
// Settings became three pages in 6.0; the old address redirects (through 6.0.x).
export const Route = createFileRoute("/dashboard/settings/job-search")({
beforeLoad: () => {
throw redirect({ to: "/dashboard/settings/integrations", replace: true });
throw redirect({ to: "/dashboard/settings/ai", replace: true });
},
});
@@ -1,22 +1,6 @@
import { t } from "@lingui/core/macro";
import { GearSixIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { Separator } from "@reactive-resume/ui/components/separator";
import { PreferencesSettingsPage } from "@/features/settings/pages/preferences";
import { DashboardHeader } from "../-components/header";
import { PreferencesSettings } from "@/features/settings/preferences";
export const Route = createFileRoute("/dashboard/settings/preferences")({
component: RouteComponent,
component: PreferencesSettings,
});
function RouteComponent() {
return (
<div className="space-y-4">
<DashboardHeader icon={GearSixIcon} title={t`Preferences`} />
<Separator />
<PreferencesSettingsPage />
</div>
);
}
@@ -1,24 +1,8 @@
import { t } from "@lingui/core/macro";
import { UserCircleIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { Separator } from "@reactive-resume/ui/components/separator";
import { ProfileSettingsPage } from "@/features/settings/pages/profile";
import { DashboardHeader } from "../-components/header";
import { createFileRoute, redirect } from "@tanstack/react-router";
// Settings became three pages in 6.0; the old address redirects (through 6.0.x).
export const Route = createFileRoute("/dashboard/settings/profile")({
component: RouteComponent,
beforeLoad: () => {
throw redirect({ to: "/dashboard/settings/account", replace: true });
},
});
function RouteComponent() {
const { session } = Route.useRouteContext();
return (
<div className="space-y-4">
<DashboardHeader icon={UserCircleIcon} title={t`Profile`} />
<Separator />
<ProfileSettingsPage session={session} />
</div>
);
}
@@ -1,34 +1,91 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import { t } from "@lingui/core/macro";
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import { Trans } from "@lingui/react/macro";
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { Icon } from "@reactive-resume/ui/components/icon";
// Settings leave the app sidebar in 6.0; until they're regrouped (M11) the pages sit behind this tab strip.
const PAGES = [
{ to: "/dashboard/settings/profile", label: () => t`Profile` },
{ to: "/dashboard/settings/preferences", label: () => t`Preferences` },
{ to: "/dashboard/settings/authentication", label: () => t`Authentication` },
{ to: "/dashboard/settings/api-keys", label: () => t`API Keys` },
{ to: "/dashboard/settings/integrations", label: () => t`Integrations` },
{ to: "/dashboard/settings/account", label: () => t`Account` },
] as const;
type Page = {
to: "/dashboard/settings/account" | "/dashboard/settings/preferences" | "/dashboard/settings/ai";
icon: IconName;
label: () => string;
};
// Six pages became three, each with one job (README §5.9).
const SETTINGS_PAGES: Page[] = [
{ to: "/dashboard/settings/account", icon: "account_circle", label: () => t`Account` },
{ to: "/dashboard/settings/preferences", icon: "tune", label: () => t`Preferences` },
{ to: "/dashboard/settings/ai", icon: "hub", label: () => t`AI & developer` },
];
export const Route = createFileRoute("/dashboard/settings")({ component: RouteComponent });
function RouteComponent() {
const pathname = useRouterState({ select: (state) => state.location.pathname });
const isRoot = pathname.replace(/\/$/, "") === "/dashboard/settings";
return (
<div className="mx-auto grid w-full max-w-[1180px] content-start gap-5 px-8 py-8 max-sm:px-4 max-sm:py-5">
<nav aria-label={t`Settings`} className="flex gap-5 overflow-x-auto border-line border-b">
{PAGES.map((page) => (
<Link
key={page.to}
to={page.to}
className="flex h-10 shrink-0 items-center font-medium text-ink-2 text-sm hover:text-ink"
activeProps={{ className: "text-ink shadow-[inset_0_-2px_0_var(--ink)]", "aria-current": "page" }}
<div className="grid min-h-full content-start lg:grid-cols-[220px_minmax(0,1fr)] lg:content-stretch">
<nav
aria-label={t`Settings`}
className="flex flex-col gap-1 border-line max-sm:hidden max-lg:border-b lg:border-e lg:py-7 lg:ps-6 lg:pe-3"
>
<h1 className="ms-2 mb-3.5 font-display font-medium text-[26px] leading-8 max-lg:hidden">
<Trans>Settings</Trans>
</h1>
<div className="flex gap-1 max-lg:overflow-x-auto max-lg:px-6 max-lg:py-2 lg:flex-col">
{SETTINGS_PAGES.map((page) => (
<Link
key={page.to}
to={page.to}
className="flex h-10 shrink-0 items-center gap-2.5 rounded-lg px-2.5 font-medium text-ink-2 text-sm transition-colors duration-quick hover:bg-hover"
activeProps={{ className: "bg-sunken text-ink", "aria-current": "page" }}
>
<Icon name={page.icon} size={20} />
{page.label()}
</Link>
))}
</div>
<p className="mt-auto px-2.5 text-ink-3 text-xs leading-[18px] max-lg:hidden">
<Trans>Reactive Resume {__APP_VERSION__} · MIT</Trans>
<br />
<a className="underline" href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer">
<Trans>Docs</Trans>
</a>
{" · "}
<a
className="underline"
href="https://github.com/reactive-resume/reactive-resume"
target="_blank"
rel="noopener noreferrer"
>
{page.label()}
</Link>
))}
<Trans>Source</Trans>
</a>
{" · "}
<a
className="underline"
href="https://opencollective.com/reactive-resume/donate"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Donate</Trans>
</a>
</p>
</nav>
<Outlet />
<div className="min-w-0 px-12 pt-8 pb-16 max-sm:px-4 max-sm:pt-4 max-lg:px-6">
{!isRoot && (
<Link
to="/dashboard/settings"
className="mb-4 inline-flex h-9 items-center gap-1 text-ink-2 text-sm sm:hidden"
>
<Icon name="chevron_left" size={20} />
<Trans>Settings</Trans>
</Link>
)}
<div className="grid max-w-[680px] gap-8">
<Outlet />
</div>
</div>
</div>
);
}
+3
View File
@@ -672,6 +672,9 @@ importers:
es-toolkit:
specifier: ^1.52.0
version: 1.52.0
fflate:
specifier: ^0.8.3
version: 0.8.3
fuse.js:
specifier: ^7.5.0
version: 7.5.0