From 9f809890e47470516f56446899647018a11ff1df Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 00:33:37 +0200 Subject: [PATCH] feat(web): settings in three pages that save as you go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/package.json | 1 + apps/web/src/dialogs/api-key/create.tsx | 243 ------ apps/web/src/dialogs/api-key/registry.tsx | 6 - apps/web/src/dialogs/renderers.tsx | 3 +- apps/web/src/dialogs/schemas.ts | 2 +- apps/web/src/dialogs/store.test.ts | 16 +- .../features/assistant/assistant-panel.tsx | 2 +- .../src/features/assistant/provider-setup.tsx | 2 +- .../ats-checker/ai-review/ai-review-card.tsx | 2 +- .../src/features/auth/pages/error.test.tsx | 2 +- apps/web/src/features/auth/pages/error.tsx | 2 +- .../command-palette/pages/navigation.tsx | 56 +- .../resume/editor/check/writing-tab.tsx | 4 +- .../hooks.tsx => account/auth-hooks.ts} | 27 +- .../src/features/settings/account/data.tsx | 153 ++++ .../features/settings/account/export.test.ts | 34 + .../src/features/settings/account/export.ts | 24 + .../src/features/settings/account/page.tsx | 45 ++ .../src/features/settings/account/profile.tsx | 271 +++++++ .../features/settings/account/security.tsx | 168 +++++ .../features/settings/ai/api-keys.test.tsx | 69 ++ .../web/src/features/settings/ai/api-keys.tsx | 317 ++++++++ .../src/features/settings/ai/catalog.test.ts | 32 + apps/web/src/features/settings/ai/catalog.ts | 153 ++++ apps/web/src/features/settings/ai/mcp.tsx | 44 ++ apps/web/src/features/settings/ai/page.tsx | 14 + .../src/features/settings/ai/providers.tsx | 480 ++++++++++++ .../authentication/components/passkeys.tsx | 165 ----- .../authentication/components/password.tsx | 39 - .../components/social-provider.tsx | 54 -- .../authentication/components/two-factor.tsx | 48 -- .../settings/authentication/index.tsx | 27 - .../components/ai-section.test.tsx | 239 ------ .../integrations/components/ai-section.tsx | 692 ------------------ .../features/settings/integrations/index.tsx | 9 - .../src/features/settings/pages/account.tsx | 128 ---- .../features/settings/pages/api-keys.test.tsx | 69 -- .../src/features/settings/pages/api-keys.tsx | 135 ---- .../features/settings/pages/preferences.tsx | 38 - .../src/features/settings/pages/profile.tsx | 274 ------- .../web/src/features/settings/preferences.tsx | 95 +++ apps/web/src/features/settings/root.tsx | 76 ++ apps/web/src/features/settings/section.tsx | 47 ++ apps/web/src/features/theme/combobox.tsx | 28 - apps/web/src/features/user/dropdown-menu.tsx | 2 +- apps/web/src/routeTree.gen.ts | 44 +- .../letter/-components/write-panel.tsx | 2 +- .../dashboard/-components/app-shell.tsx | 17 +- .../dashboard/-components/header.test.tsx | 28 - .../routes/dashboard/-components/header.tsx | 22 - .../src/routes/dashboard/settings/account.tsx | 20 +- apps/web/src/routes/dashboard/settings/ai.tsx | 6 + .../routes/dashboard/settings/api-keys.tsx | 24 +- .../settings/authentication/index.tsx | 24 +- .../src/routes/dashboard/settings/index.tsx | 11 + .../dashboard/settings/integrations/route.tsx | 24 +- .../routes/dashboard/settings/job-search.tsx | 3 +- .../routes/dashboard/settings/preferences.tsx | 20 +- .../src/routes/dashboard/settings/profile.tsx | 26 +- .../src/routes/dashboard/settings/route.tsx | 101 ++- pnpm-lock.yaml | 3 + 61 files changed, 2237 insertions(+), 2475 deletions(-) delete mode 100644 apps/web/src/dialogs/api-key/create.tsx delete mode 100644 apps/web/src/dialogs/api-key/registry.tsx rename apps/web/src/features/settings/{authentication/components/hooks.tsx => account/auth-hooks.ts} (83%) create mode 100644 apps/web/src/features/settings/account/data.tsx create mode 100644 apps/web/src/features/settings/account/export.test.ts create mode 100644 apps/web/src/features/settings/account/export.ts create mode 100644 apps/web/src/features/settings/account/page.tsx create mode 100644 apps/web/src/features/settings/account/profile.tsx create mode 100644 apps/web/src/features/settings/account/security.tsx create mode 100644 apps/web/src/features/settings/ai/api-keys.test.tsx create mode 100644 apps/web/src/features/settings/ai/api-keys.tsx create mode 100644 apps/web/src/features/settings/ai/catalog.test.ts create mode 100644 apps/web/src/features/settings/ai/catalog.ts create mode 100644 apps/web/src/features/settings/ai/mcp.tsx create mode 100644 apps/web/src/features/settings/ai/page.tsx create mode 100644 apps/web/src/features/settings/ai/providers.tsx delete mode 100644 apps/web/src/features/settings/authentication/components/passkeys.tsx delete mode 100644 apps/web/src/features/settings/authentication/components/password.tsx delete mode 100644 apps/web/src/features/settings/authentication/components/social-provider.tsx delete mode 100644 apps/web/src/features/settings/authentication/components/two-factor.tsx delete mode 100644 apps/web/src/features/settings/authentication/index.tsx delete mode 100644 apps/web/src/features/settings/integrations/components/ai-section.test.tsx delete mode 100644 apps/web/src/features/settings/integrations/components/ai-section.tsx delete mode 100644 apps/web/src/features/settings/integrations/index.tsx delete mode 100644 apps/web/src/features/settings/pages/account.tsx delete mode 100644 apps/web/src/features/settings/pages/api-keys.test.tsx delete mode 100644 apps/web/src/features/settings/pages/api-keys.tsx delete mode 100644 apps/web/src/features/settings/pages/preferences.tsx delete mode 100644 apps/web/src/features/settings/pages/profile.tsx create mode 100644 apps/web/src/features/settings/preferences.tsx create mode 100644 apps/web/src/features/settings/root.tsx create mode 100644 apps/web/src/features/settings/section.tsx delete mode 100644 apps/web/src/features/theme/combobox.tsx delete mode 100644 apps/web/src/routes/dashboard/-components/header.test.tsx delete mode 100644 apps/web/src/routes/dashboard/-components/header.tsx create mode 100644 apps/web/src/routes/dashboard/settings/ai.tsx create mode 100644 apps/web/src/routes/dashboard/settings/index.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 2df3f02ba..d7e0f946c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/dialogs/api-key/create.tsx b/apps/web/src/dialogs/api-key/create.tsx deleted file mode 100644 index 04de5e3fb..000000000 --- a/apps/web/src/dialogs/api-key/create.tsx +++ /dev/null @@ -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(null); - - if (apiKey) return ; - - return ; -} - -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 ( - - - - - Create a new API key - - - - This creates a new API key, which lets other programs read and change your resume data through the Reactive - Resume API. - - - - -
{ - event.preventDefault(); - event.stopPropagation(); - void form.handleSubmit(); - }} - > - - {(field) => ( - 0}> - - Name - - field.handleChange(event.target.value)} - /> - } - /> - - - Names help you tell keys apart, so name it after what you will use it for. - - - )} - - - - {(field) => ( - 0}> - - Expires in - - 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`, - }, - ]} - /> - } - /> - - - )} - - - - - -
-
- ); -}; - -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 ( - - - - - Here's your new API key - - - Copy this secret key and use it in your applications to access your data. - - - -
- - - - - - - - - - - This key is shown only once. Copy it before you close this dialog. - -
- - - - -
- ); -}; diff --git a/apps/web/src/dialogs/api-key/registry.tsx b/apps/web/src/dialogs/api-key/registry.tsx deleted file mode 100644 index ca462c325..000000000 --- a/apps/web/src/dialogs/api-key/registry.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { AnyDialogRendererEntry } from "../schemas"; -import { CreateApiKeyDialog } from "./create"; - -export const apiKeyDialogRenderers: readonly AnyDialogRendererEntry[] = [ - { type: "api-key.create", render: () => }, -]; diff --git a/apps/web/src/dialogs/renderers.tsx b/apps/web/src/dialogs/renderers.tsx index b7c859115..d95647d95 100644 --- a/apps/web/src/dialogs/renderers.tsx +++ b/apps/web/src/dialogs/renderers.tsx @@ -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, ), ); diff --git a/apps/web/src/dialogs/schemas.ts b/apps/web/src/dialogs/schemas.ts index 2158b7b6f..33e617b90 100644 --- a/apps/web/src/dialogs/schemas.ts +++ b/apps/web/src/dialogs/schemas.ts @@ -4,7 +4,7 @@ import type { NewDocumentDialogData } from "@/features/documents/new-document-di type EmptyDialog = { [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; diff --git a/apps/web/src/dialogs/store.test.ts b/apps/web/src/dialogs/store.test.ts index 1c6e787b3..1c1381f5a 100644 --- a/apps/web/src/dialogs/store.test.ts +++ b/apps/web/src/dialogs/store.test.ts @@ -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, }); diff --git a/apps/web/src/features/assistant/assistant-panel.tsx b/apps/web/src/features/assistant/assistant-panel.tsx index f0c8852cb..a4ab24b75 100644 --- a/apps/web/src/features/assistant/assistant-panel.tsx +++ b/apps/web/src/features/assistant/assistant-panel.tsx @@ -148,7 +148,7 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) { ))} - void navigate({ to: "/dashboard/settings/integrations" })}> + void navigate({ to: "/dashboard/settings/ai" })}> Connect another… diff --git a/apps/web/src/features/assistant/provider-setup.tsx b/apps/web/src/features/assistant/provider-setup.tsx index 96c8efb4c..9bbc548b0 100644 --- a/apps/web/src/features/assistant/provider-setup.tsx +++ b/apps/web/src/features/assistant/provider-setup.tsx @@ -66,7 +66,7 @@ export function ProviderSetup() {

More providers and options are in{" "} - + Settings . The rest of the app works fully without AI. diff --git a/apps/web/src/features/ats-checker/ai-review/ai-review-card.tsx b/apps/web/src/features/ats-checker/ai-review/ai-review-card.tsx index 3207e062d..25a150635 100644 --- a/apps/web/src/features/ats-checker/ai-review/ai-review-card.tsx +++ b/apps/web/src/features/ats-checker/ai-review/ai-review-card.tsx @@ -149,7 +149,7 @@ function NoProviderState() { variant="secondary" nativeButton={false} render={ - + Open Integrations Settings diff --git a/apps/web/src/features/auth/pages/error.test.tsx b/apps/web/src/features/auth/pages/error.test.tsx index d2a06dfdb..8277b493a 100644 --- a/apps/web/src/features/auth/pages/error.test.tsx +++ b/apps/web/src/features/auth/pages/error.test.tsx @@ -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"); }); }); diff --git a/apps/web/src/features/auth/pages/error.tsx b/apps/web/src/features/auth/pages/error.tsx index 952eb1bb4..cca85048e 100644 --- a/apps/web/src/features/auth/pages/error.tsx +++ b/apps/web/src/features/auth/pages/error.tsx @@ -83,7 +83,7 @@ export function AuthErrorPage({ code, description }: AuthErrorPageProps) { {returnsToSettings ? ( - + Back to authentication settings diff --git a/apps/web/src/features/command-palette/pages/navigation.tsx b/apps/web/src/features/command-palette/pages/navigation.tsx index c975c6452..9ee24991e 100644 --- a/apps/web/src/features/command-palette/pages/navigation.tsx +++ b/apps/web/src/features/command-palette/pages/navigation.tsx @@ -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() { Settings}> 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")} > - Profile + Account onNavigate("/dashboard/settings/preferences")} > @@ -135,39 +140,12 @@ export function NavigationCommandGroup() { onNavigate("/dashboard/settings/authentication")} - > - - Authentication - - - 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")} > - API Keys - - - onNavigate("/dashboard/settings/integrations")} - > - - Integrations - - - onNavigate("/dashboard/settings/account")} - > - - Account + AI & developer diff --git a/apps/web/src/features/resume/editor/check/writing-tab.tsx b/apps/web/src/features/resume/editor/check/writing-tab.tsx index 331ea0311..993d0a95a 100644 --- a/apps/web/src/features/resume/editor/check/writing-tab.tsx +++ b/apps/web/src/features/resume/editor/check/writing-tab.tsx @@ -99,7 +99,7 @@ export function WritingTab({ data, issues }: WritingTabProps) {

Open AI settings @@ -144,7 +144,7 @@ export function WritingTab({ data, issues }: WritingTabProps) { - + Open AI settings diff --git a/apps/web/src/features/settings/authentication/components/hooks.tsx b/apps/web/src/features/settings/account/auth-hooks.ts similarity index 83% rename from apps/web/src/features/settings/authentication/components/hooks.tsx rename to apps/web/src/features/settings/account/auth-hooks.ts index 84c7cd950..ac46a662e 100644 --- a/apps/web/src/features/settings/authentication/components/hooks.tsx +++ b/apps/web/src/features/settings/account/auth-hooks.ts @@ -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", () => ) - .with("passkey", () => ) - .with("google", () => ) - .with("github", () => ) - .with("linkedin", () => ) - .with("custom", () => ) - .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 }; } diff --git a/apps/web/src/features/settings/account/data.tsx b/apps/web/src/features/settings/account/data.tsx new file mode 100644 index 000000000..d5ec4cdfa --- /dev/null +++ b/apps/web/src/features/settings/account/data.tsx @@ -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 ( + Your data}> + Export everything} + description={All documents and applications as JSON, in one zip} + > + + + Delete account} + description={Permanently removes everything. Export first if you want a copy.} + > + + + + + ); +} + +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 ( + { + onOpenChange(next); + if (!next) setTyped(""); + }} + > + + + + Delete your account? + + + ,{" "} + ,{" "} + {" "} + and every public link are removed permanently. This can't be undone. + + +
+ + setTyped(event.target.value)} + /> +
+ + + Keep account + + remove.mutate()} + > + Delete + + +
+
+ ); +} diff --git a/apps/web/src/features/settings/account/export.test.ts b/apps/web/src/features/settings/account/export.test.ts new file mode 100644 index 000000000..db072c758 --- /dev/null +++ b/apps/web/src/features/settings/account/export.test.ts @@ -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" }, + ]); + }); +}); diff --git a/apps/web/src/features/settings/account/export.ts b/apps/web/src/features/settings/account/export.ts new file mode 100644 index 000000000..d7ccfb86c --- /dev/null +++ b/apps/web/src/features/settings/account/export.ts @@ -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 = { + "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); +} diff --git a/apps/web/src/features/settings/account/page.tsx b/apps/web/src/features/settings/account/page.tsx new file mode 100644 index 000000000..59ef6afdd --- /dev/null +++ b/apps/web/src/features/settings/account/page.tsx @@ -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 ( + <> + + + +
+ +
+ + ); +} + +export function SignOutButton() { + const router = useRouter(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return ( + + ); +} diff --git a/apps/web/src/features/settings/account/profile.tsx b/apps/web/src/features/settings/account/profile.tsx new file mode 100644 index 000000000..da9a04875 --- /dev/null +++ b/apps/web/src/features/settings/account/profile.tsx @@ -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; + 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(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) => setDraft(event.target.value), + onBlur: () => void commit(), + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + setDraft(value); + setError(null); + } + }, + }; + + return ( +
+ + {prefix ? ( + + + {prefix} + + + + ) : ( + + )} + {error ? ( + + ) : ( + hint && ( +

+ {hint} +

+ ) + )} +
+ ); +} + +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[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 ( + Profile}> + updateUser({ image })} /> + +
+ (nameSchema.safeParse(value).success ? null : t`Enter a name, up to 64 characters.`)} + save={(name) => updateUser({ name })} + /> + + usernameSchema.safeParse(value).success + ? null + : t`Use lowercase letters, numbers, dots, hyphens and underscores.` + } + save={(username) => updateUser({ username, displayUsername: username })} + /> + (emailSchema.safeParse(value).success ? null : t`Enter a valid email address.`)} + save={changeEmail} + hint={ + <> + + Changing it sends a confirmation to the new address. Your username is part of every public link. + {" "} + {user.emailVerified ? null : smtpEnabled ? ( + + ) : ( + Email delivery isn't set up on this server, so the address isn't verified. + )} + + } + /> +
+
+ ); +} + +type ProfilePhotoProps = { + name: string; + image: string | null; + onChange: (image: string | null) => Promise; +}; + +/** The account photo, shown in the app. Resumes keep their own picture. */ +function ProfilePhoto({ name, image, onChange }: ProfilePhotoProps) { + const input = useRef(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 ( +
+ + + + {getInitials(name)} + + + choose(event.target.files?.[0])} + /> + + {image && ( + + )} +
+ ); +} diff --git a/apps/web/src/features/settings/account/security.tsx b/apps/web/src/features/settings/account/security.tsx new file mode 100644 index 000000000..bac069eda --- /dev/null +++ b/apps/web/src/features/settings/account/security.tsx @@ -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 ( + Sign-in & security}> + Password} + description={ + password ? ( + Last changed in {monthYear(new Date(password.updatedAt))} + ) : ( + You sign in without one + ) + } + > + {password ? ( + + ) : ( + + Set a password + + )} + + + {password && ( + Two-step verification} + description={A code from an authenticator app at sign-in} + > + + openDialog(twoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined) + } + /> + + )} + + + + {social.map((provider) => { + const account = getAccountByProviderId(provider); + const name = + provider === "custom" ? (enabledProviders.custom ?? getProviderName(provider)) : getProviderName(provider); + return ( + Connected : Not connected} + > + {account ? ( + + ) : ( + + )} + + ); + })} + + ); +} + +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 ( +
+ Passkeys} + description={Sign in with your fingerprint, face or device PIN} + > + + + {passkeys.length > 0 && ( +
    + {passkeys.map((passkey) => ( +
  • + {passkey.name || t`Unnamed passkey`} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/features/settings/ai/api-keys.test.tsx b/apps/web/src/features/settings/ai/api-keys.test.tsx new file mode 100644 index 000000000..502c62397 --- /dev/null +++ b/apps/web/src/features/settings/ai/api-keys.test.tsx @@ -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 }; onClose: () => void }; + +async function revoke() { + render( + + + + + , + ); + 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" }); + }); +}); diff --git a/apps/web/src/features/settings/ai/api-keys.tsx b/apps/web/src/features/settings/ai/api-keys.tsx new file mode 100644 index 000000000..fd4d68c15 --- /dev/null +++ b/apps/web/src/features/settings/ai/api-keys.tsx @@ -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 ( + API keys} + description={ + For scripts, extensions and MCP clients. Keys act as you and can read and edit your documents. + } + action={ + + } + > + {isLoading ? null : keys.length === 0 ? ( +

+ No keys yet. +

+ ) : ( + + + + + + + + + + + + {keys.map((key) => ( + + + + + + + + ))} + +
+ Name + + Created + + Last used + + Expires + + + Actions + +
+ {key.name || {key.start}…} + + + Created{" "} + + {shortDate(key.createdAt)} + + + Last used{" "} + + {key.lastRequest ? shortDate(key.lastRequest) : Never} + + + Expires{" "} + + {key.expiresAt ? shortDate(key.expiresAt) : Never} + + +
+ )} + + void refresh()} /> +
+ ); +} + +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(null); + const [failure, setFailure] = useState(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 ( + + + + + New API key + + + Keys act as you and can read and edit your documents. + + + + {key ? ( +
+

+ + Copy it now. For your security, it won't be shown again. +

+
+ {key} + +
+ + + +
+ ) : ( +
{ + event.preventDefault(); + if (name.trim()) void create(); + }} + > +
+ + setName(event.target.value)} + /> +
+
+ + Expires + +
+ {EXPIRIES.map((option, index) => ( + + ))} +
+
+ {failure && ( +

+ {failure} +

+ )} + + + + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/features/settings/ai/catalog.test.ts b/apps/web/src/features/settings/ai/catalog.test.ts new file mode 100644 index 000000000..1a1435ea5 --- /dev/null +++ b/apps/web/src/features/settings/ai/catalog.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/features/settings/ai/catalog.ts b/apps/web/src/features/settings/ai/catalog.ts new file mode 100644 index 000000000..034b20c3c --- /dev/null +++ b/apps/web/src/features/settings/ai/catalog.ts @@ -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 & { 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 }; +} diff --git a/apps/web/src/features/settings/ai/mcp.tsx b/apps/web/src/features/settings/ai/mcp.tsx new file mode 100644 index 000000000..94067419c --- /dev/null +++ b/apps/web/src/features/settings/ai/mcp.tsx @@ -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 ( + MCP server} + description={ + Let AI clients like Claude or Cursor read and edit your resumes, using an API key above. + } + > +
+ {address} + +
+ + Setup guide + +
+ ); +} diff --git a/apps/web/src/features/settings/ai/page.tsx b/apps/web/src/features/settings/ai/page.tsx new file mode 100644 index 000000000..4c7b5afca --- /dev/null +++ b/apps/web/src/features/settings/ai/page.tsx @@ -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 ( + <> + + + + + ); +} diff --git a/apps/web/src/features/settings/ai/providers.tsx b/apps/web/src/features/settings/ai/providers.tsx new file mode 100644 index 000000000..c8888f8e5 --- /dev/null +++ b/apps/web/src/features/settings/ai/providers.tsx @@ -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(null); + + return ( + AI providers} description={Your keys, stored encrypted}> + {error ? ( +

+ {isConfigError(error) ? ( + AI providers aren't available on this server until ENCRYPTION_SECRET is set. + ) : ( + AI providers couldn't be loaded. Reload to try again. + )} +

+ ) : isLoading ? ( +

+ + Loading providers… +

+ ) : ( + <> + {providers?.map((provider) => ( + setEditing(provider)} /> + ))} + + + )} + + + {editing && setEditing(null)} />} +
+ ); +} + +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(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 ( +
+
+ + {providerLabel(provider.provider).slice(0, 2)} + + + + {provider.label} + {!provider.enabled && ( + + Off + + )} + + + + Model {provider.model} · key ends in {keyEnding(provider.apiKeyPreview)} + + + + + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} + +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) => onChange({ ...value, ...patch }); + const defaults = providerDefaults(provider); + + return ( +
+
+ + set({ apiKey: event.target.value })} + {...secretInputProps} + /> +
+
+ + set({ model: event.target.value })} + {...secretInputProps} + /> +
+
+ + set({ label: event.target.value })} + /> +
+
+ + set({ baseURL: event.target.value })} + {...secretInputProps} + /> +
+
+ ); +} + +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("openai"); + const [fields, setFields] = useState({ label: "", apiKey: "", ...providerDefaults("openai") }); + const [failure, setFailure] = useState(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 ( + { + onOpenChange(next); + if (!next) reset(); + }} + > + + + + Add provider + + + The key is encrypted on the server and never shown again. Saving tests the connection. + + +
{ + event.preventDefault(); + if (ready && !pending) void save(); + }} + > +
+ + { + if (!next) return; + setProvider(next); + setFields((current) => ({ ...current, ...providerDefaults(next) })); + }} + /> +
+ + {failure && ( +

+ {failure} +

+ )} + + + + +
+
+ ); +} + +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({ + label: provider.label, + model: provider.model, + baseURL: provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider] ?? "", + apiKey: "", + }); + const [failure, setFailure] = useState(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 ( + !open && onClose()}> + + + {provider.label} + {providerLabel(provider.provider)} + +
{ + event.preventDefault(); + if (changed) void save(); + }} + > + +
+ + + Use this provider + + + Only providers that pass their test can be turned on. + + + +
+ {failure && ( +

+ {failure} +

+ )} + + + + + +
+
+ ); +} diff --git a/apps/web/src/features/settings/authentication/components/passkeys.tsx b/apps/web/src/features/settings/authentication/components/passkeys.tsx deleted file mode 100644 index 135640b33..000000000 --- a/apps/web/src/features/settings/authentication/components/passkeys.tsx +++ /dev/null @@ -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 ( -
- - -
-
-

- - Passkeys -

- - -
- - {passkeys.length === 0 && ( -

- No passkeys registered yet. -

- )} - - {passkeys.length > 0 && ( -
- {passkeys.map((passkey) => { - return ( -
-

{passkey.name ?? t`Unnamed passkey`}

- -
- -
-
- ); - })} -
- )} -
-
- ); -} diff --git a/apps/web/src/features/settings/authentication/components/password.tsx b/apps/web/src/features/settings/authentication/components/password.tsx deleted file mode 100644 index 9c8913937..000000000 --- a/apps/web/src/features/settings/authentication/components/password.tsx +++ /dev/null @@ -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 ( -
-

- - Password -

- - {hasPassword ? ( - - ) : ( -
- ); -} diff --git a/apps/web/src/features/settings/authentication/components/social-provider.tsx b/apps/web/src/features/settings/authentication/components/social-provider.tsx deleted file mode 100644 index 7cba6f824..000000000 --- a/apps/web/src/features/settings/authentication/components/social-provider.tsx +++ /dev/null @@ -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 ( -
- - -
-

- {providerIcon} - {providerName} -

- - {isConnected ? ( - - ) : ( - - )} -
-
- ); -} diff --git a/apps/web/src/features/settings/authentication/components/two-factor.tsx b/apps/web/src/features/settings/authentication/components/two-factor.tsx deleted file mode 100644 index dc6b65772..000000000 --- a/apps/web/src/features/settings/authentication/components/two-factor.tsx +++ /dev/null @@ -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 ( -
- - -
-

- {hasTwoFactor ? : } - Two-Factor Authentication -

- - -
-
- ); -} diff --git a/apps/web/src/features/settings/authentication/index.tsx b/apps/web/src/features/settings/authentication/index.tsx deleted file mode 100644 index f659911a5..000000000 --- a/apps/web/src/features/settings/authentication/index.tsx +++ /dev/null @@ -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 ( -
- - - - - - - {"google" in enabledProviders && } - - {"github" in enabledProviders && } - - {"linkedin" in enabledProviders && } - - {"custom" in enabledProviders && } -
- ); -} diff --git a/apps/web/src/features/settings/integrations/components/ai-section.test.tsx b/apps/web/src/features/settings/integrations/components/ai-section.test.tsx deleted file mode 100644 index cca31612a..000000000 --- a/apps/web/src/features/settings/integrations/components/ai-section.test.tsx +++ /dev/null @@ -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) => ( - - ), -})); - -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 => ({ - 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( - - - , - ); - -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"] }); - }); -}); diff --git a/apps/web/src/features/settings/integrations/components/ai-section.tsx b/apps/web/src/features/settings/integrations/components/ai-section.tsx deleted file mode 100644 index d4509d260..000000000 --- a/apps/web/src/features/settings/integrations/components/ai-section.tsx +++ /dev/null @@ -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 & { 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 ( - - Connected - - ); - } - if (provider.testStatus === "failure") { - return ( - - Connection failed - - ); - } - return ( - - Not connected - - ); -} - -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 ( -
-
-
-

{provider.label}

- {statusBadge(provider)} - {provider.enabled ? ( - - Enabled - - ) : null} -
- -
-

- {providerLabel(provider.provider)} - {isEditingModel ? "" : ` · ${provider.model}`} -

- {isEditingModel ? ( -
- setModel(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") saveModel(); - if (event.key === "Escape") setIsEditingModel(false); - }} - /> - - -
- ) : null} -

{provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider]}

-

- Key: {provider.apiKeyPreview} -

- {provider.testError ?

{provider.testError}

: null} -
-
- -
-
- - updateProvider( - { id: provider.id, enabled }, - { - onSuccess: () => void invalidate(), - onError: (error) => - toast.add({ - type: "error", - description: getOrpcErrorMessage(error, { fallback: t`Failed to update provider.` }), - }), - }, - ) - } - /> - - Use - -
- - - - - - -
-
- ); -} - -type SaveResult = { ok: boolean; message: string }; - -function CreateProviderForm() { - const queryClient = useQueryClient(); - const [form, setForm] = useState(emptyForm); - const [result, setResult] = useState(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 ( -
-
-
- -
-

- Add Provider -

-
- -
-
- - { - if (!provider) return; - setForm((current) => ({ ...current, provider, ...providerDefaults(provider) })); - }} - /> -
- -
- - setForm((current) => ({ ...current, apiKey: event.target.value }))} - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - data-lpignore="true" - data-bwignore="true" - data-1p-ignore="true" - /> -
- -
- - Advanced - - -
-
- - setForm((current) => ({ ...current, label: event.target.value }))} - placeholder={selectedOption?.label ? String(selectedOption.label) : t`Work OpenAI`} - /> -
- -
- - setForm((current) => ({ ...current, model: event.target.value }))} - placeholder="gpt-4.1" - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - /> -
- -
- - setForm((current) => ({ ...current, baseURL: event.target.value }))} - placeholder={selectedOption?.defaultBaseURL || "https://gateway.example.com/v1"} - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - /> -
-
-
-
- - {result ? ( -
- {result.ok ? ( - - ) : ( - - )} - {result.message} -
- ) : null} - -
- -
-
- ); -} - -export function AISettingsSection() { - const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions()); - const { hasUsableProvider } = useHasUsableAiProvider(); - const isConfigError = isAiProviderConfigError(error); - - return ( -
-
-
-

- AI Providers -

-

- API keys are encrypted on the server and never shown again after saving. -

-
- -

- {hasUsableProvider ? ( - - ) : ( - - )} - - {hasUsableProvider ? Agent ready : No tested provider} - -

-
- - {error ? ( -
- {isConfigError ? ( - AI provider management is unavailable until REDIS_URL and ENCRYPTION_SECRET are configured. - ) : ( - AI provider management is unavailable. Please try again. - )} -
- ) : null} - - {error ? null : } - -
- {isLoading ? ( -
- - Loading providers… -
- ) : null} - - {providers?.length === 0 ? ( -
- Add and test a provider before starting an agent thread. -
- ) : null} - - {providers?.map((provider) => ( - - ))} -
-
- ); -} diff --git a/apps/web/src/features/settings/integrations/index.tsx b/apps/web/src/features/settings/integrations/index.tsx deleted file mode 100644 index 605c4b5db..000000000 --- a/apps/web/src/features/settings/integrations/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { AISettingsSection } from "./components/ai-section"; - -export function IntegrationsSettingsPage() { - return ( -
- -
- ); -} diff --git a/apps/web/src/features/settings/pages/account.tsx b/apps/web/src/features/settings/pages/account.tsx deleted file mode 100644 index 2b34445e1..000000000 --- a/apps/web/src/features/settings/pages/account.tsx +++ /dev/null @@ -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 ( -
-
-

- Download a copy of all your data, including your profile and every resume, as a JSON file. -

- - -
- -
- -

- To delete your account, type the confirmation text below, then click the button. -

- - setConfirmationText(e.target.value)} - placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`} - /> - - -
- ); -} diff --git a/apps/web/src/features/settings/pages/api-keys.test.tsx b/apps/web/src/features/settings/pages/api-keys.test.tsx deleted file mode 100644 index 507daccdd..000000000 --- a/apps/web/src/features/settings/pages/api-keys.test.tsx +++ /dev/null @@ -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( - - - - - - - , - ); - - await waitFor(() => expect(screen.getByText("rr_no_expiry...")).toBeInTheDocument()); - expect(screen.getByText("Never expires")).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/features/settings/pages/api-keys.tsx b/apps/web/src/features/settings/pages/api-keys.tsx deleted file mode 100644 index 415305ac7..000000000 --- a/apps/web/src/features/settings/pages/api-keys.tsx +++ /dev/null @@ -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 ( -
-
-
- -
- -
-

- How do I use the API? -

- -

- - The API documentation shows how to connect Reactive Resume to your own applications. It covers the - endpoints, the request format, and authentication. - -

- -
-
- - - -
- - - {apiKeys.map((key) => ( -
- - -
-

{key.start}...

-
- {key.expiresAt ? ( - Expires on {key.expiresAt.toLocaleDateString()} - ) : ( - Never expires - )} -
-
- - -
- ))} -
-
- ); -} diff --git a/apps/web/src/features/settings/pages/preferences.tsx b/apps/web/src/features/settings/pages/preferences.tsx deleted file mode 100644 index baac230e0..000000000 --- a/apps/web/src/features/settings/pages/preferences.tsx +++ /dev/null @@ -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 ( -
-
- - -
- -
- - -
-
- ); -} diff --git a/apps/web/src/features/settings/pages/profile.tsx b/apps/web/src/features/settings/pages/profile.tsx deleted file mode 100644 index 4447fb52a..000000000 --- a/apps/web/src/features/settings/pages/profile.tsx +++ /dev/null @@ -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 ( -
{ - event.preventDefault(); - event.stopPropagation(); - void form.handleSubmit(); - }} - > - - {(field) => ( - 0}> - - Name - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - - - - {(field) => ( - 0}> - - Username - - field.handleChange(event.target.value)} - /> - } - /> - - - )} - - - - {(field) => ( - 0}> - - Email Address - - field.handleChange(event.target.value)} - /> - } - /> - - {session.user.emailVerified === true ? ( -

- - Verified -

- ) : smtpEnabled ? ( -

- - Unverified - | - -

- ) : ( -

- Email delivery isn't configured on this instance, so verification is disabled. -

- )} -
- )} -
- - - {isDirty && ( - - - - - - )} - -
- ); -} diff --git a/apps/web/src/features/settings/preferences.tsx b/apps/web/src/features/settings/preferences.tsx new file mode 100644 index 000000000..48eea77d6 --- /dev/null +++ b/apps/web/src/features/settings/preferences.tsx @@ -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 ( + <> + Appearance}> + +

+ Resumes always render on white paper, whatever the theme. +

+
+ + Language}> +
+ + +
+

+ Interface only. Each resume sets its own language in Design.{" "} + + Help translate + +

+
+ + Motion}> + Follows your system's reduced-motion setting.} + description={Nothing in the app animates on its own.} + /> + + + ); +} + +/** Light, Dark and System as three tiles; the choice applies at once. */ +function ThemeTiles() { + const { i18n } = useLingui(); + const { theme, setTheme } = useTheme(); + + return ( +
+ {t`Theme`} + {THEMES.map((option) => { + const checked = theme === option.value; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/features/settings/root.tsx b/apps/web/src/features/settings/root.tsx new file mode 100644 index 000000000..90046af41 --- /dev/null +++ b/apps/web/src/features/settings/root.tsx @@ -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 ( + <> + + {label} + {value && {value}} + + + ); +} + +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 ( +
+
+ + + + {getInitials(session.user.name)} + + + + {session.user.name} + {session.user.email} + +
+ + + + + Help & docs + + +
+ ); +} diff --git a/apps/web/src/features/settings/section.tsx b/apps/web/src/features/settings/section.tsx new file mode 100644 index 000000000..a1233882f --- /dev/null +++ b/apps/web/src/features/settings/section.tsx @@ -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 ( +
+
+
+

{title}

+ {description &&

{description}

} +
+ {action} +
+ {children} +
+ ); +} + +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 ( +
+
+ {title} + {description && {description}} +
+ {children} +
+ ); +} diff --git a/apps/web/src/features/theme/combobox.tsx b/apps/web/src/features/theme/combobox.tsx deleted file mode 100644 index 5effb50dc..000000000 --- a/apps/web/src/features/theme/combobox.tsx +++ /dev/null @@ -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; - -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 ; -} diff --git a/apps/web/src/features/user/dropdown-menu.tsx b/apps/web/src/features/user/dropdown-menu.tsx index f11eedffa..9ac3a5a53 100644 --- a/apps/web/src/features/user/dropdown-menu.tsx +++ b/apps/web/src/features/user/dropdown-menu.tsx @@ -73,7 +73,7 @@ export function UserDropdownMenu({ children }: Props) { - void router.navigate({ to: "/dashboard/settings/profile" })}> + void router.navigate({ to: "/dashboard/settings" })}> Settings diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index e9da93a40..e55b34335 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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, }; diff --git a/apps/web/src/routes/builder/letter/-components/write-panel.tsx b/apps/web/src/routes/builder/letter/-components/write-panel.tsx index df16be6ee..46df972e1 100644 --- a/apps/web/src/routes/builder/letter/-components/write-panel.tsx +++ b/apps/web/src/routes/builder/letter/-components/write-panel.tsx @@ -450,7 +450,7 @@ function EmptyBody({ letter, application, disabled, onWrite }: EmptyBodyProps) { {canDraft && !hasUsableProvider && (

Drafting needs an AI provider.{" "} - + Open AI settings

diff --git a/apps/web/src/routes/dashboard/-components/app-shell.tsx b/apps/web/src/routes/dashboard/-components/app-shell.tsx index 9bc608ee4..ad47ce79b 100644 --- a/apps/web/src/routes/dashboard/-components/app-shell.tsx +++ b/apps/web/src/routes/dashboard/-components/app-shell.tsx @@ -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() { New - - {() => ( - - )} - + {/* The Account tab is the settings root (README §6.9, B). */} + + {tab("account_circle", t`Account`, isCurrent("/dashboard/settings"))} + ); } diff --git a/apps/web/src/routes/dashboard/-components/header.test.tsx b/apps/web/src/routes/dashboard/-components/header.test.tsx deleted file mode 100644 index 34ca37e27..000000000 --- a/apps/web/src/routes/dashboard/-components/header.test.tsx +++ /dev/null @@ -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> = {}) => - render(); - -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(); - }); -}); diff --git a/apps/web/src/routes/dashboard/-components/header.tsx b/apps/web/src/routes/dashboard/-components/header.tsx deleted file mode 100644 index bf62ecd55..000000000 --- a/apps/web/src/routes/dashboard/-components/header.tsx +++ /dev/null @@ -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 ( -
-
- -

{title}

-
- {actions ?
{actions}
: null} -
- ); -} diff --git a/apps/web/src/routes/dashboard/settings/account.tsx b/apps/web/src/routes/dashboard/settings/account.tsx index e102890d6..9209ff151 100644 --- a/apps/web/src/routes/dashboard/settings/account.tsx +++ b/apps/web/src/routes/dashboard/settings/account.tsx @@ -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 ( -
- - - - - -
- ); -} diff --git a/apps/web/src/routes/dashboard/settings/ai.tsx b/apps/web/src/routes/dashboard/settings/ai.tsx new file mode 100644 index 000000000..dad376dd0 --- /dev/null +++ b/apps/web/src/routes/dashboard/settings/ai.tsx @@ -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, +}); diff --git a/apps/web/src/routes/dashboard/settings/api-keys.tsx b/apps/web/src/routes/dashboard/settings/api-keys.tsx index 1f381dd0a..aaebdbda2 100644 --- a/apps/web/src/routes/dashboard/settings/api-keys.tsx +++ b/apps/web/src/routes/dashboard/settings/api-keys.tsx @@ -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 ( -
- - - - - -
- ); -} diff --git a/apps/web/src/routes/dashboard/settings/authentication/index.tsx b/apps/web/src/routes/dashboard/settings/authentication/index.tsx index 558406d74..53dc7d12e 100644 --- a/apps/web/src/routes/dashboard/settings/authentication/index.tsx +++ b/apps/web/src/routes/dashboard/settings/authentication/index.tsx @@ -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 ( -
- - - - - -
- ); -} diff --git a/apps/web/src/routes/dashboard/settings/index.tsx b/apps/web/src/routes/dashboard/settings/index.tsx new file mode 100644 index 000000000..0695bc025 --- /dev/null +++ b/apps/web/src/routes/dashboard/settings/index.tsx @@ -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, +}); diff --git a/apps/web/src/routes/dashboard/settings/integrations/route.tsx b/apps/web/src/routes/dashboard/settings/integrations/route.tsx index 7b728955c..409d8456c 100644 --- a/apps/web/src/routes/dashboard/settings/integrations/route.tsx +++ b/apps/web/src/routes/dashboard/settings/integrations/route.tsx @@ -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 ( -
- - - - - -
- ); -} diff --git a/apps/web/src/routes/dashboard/settings/job-search.tsx b/apps/web/src/routes/dashboard/settings/job-search.tsx index 0d3f0c4c9..df84fd89d 100644 --- a/apps/web/src/routes/dashboard/settings/job-search.tsx +++ b/apps/web/src/routes/dashboard/settings/job-search.tsx @@ -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 }); }, }); diff --git a/apps/web/src/routes/dashboard/settings/preferences.tsx b/apps/web/src/routes/dashboard/settings/preferences.tsx index 07838ded3..eff80d0a3 100644 --- a/apps/web/src/routes/dashboard/settings/preferences.tsx +++ b/apps/web/src/routes/dashboard/settings/preferences.tsx @@ -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 ( -
- - - - - -
- ); -} diff --git a/apps/web/src/routes/dashboard/settings/profile.tsx b/apps/web/src/routes/dashboard/settings/profile.tsx index d42a0a192..f2a8f7c73 100644 --- a/apps/web/src/routes/dashboard/settings/profile.tsx +++ b/apps/web/src/routes/dashboard/settings/profile.tsx @@ -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 ( -
- - - - - -
- ); -} diff --git a/apps/web/src/routes/dashboard/settings/route.tsx b/apps/web/src/routes/dashboard/settings/route.tsx index 1c2a4ab2b..fc1e46d47 100644 --- a/apps/web/src/routes/dashboard/settings/route.tsx +++ b/apps/web/src/routes/dashboard/settings/route.tsx @@ -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 ( -
-
); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de982412..0d7baec1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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