diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index 7eef7eb5..2ff2b6ae 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -8,11 +8,20 @@ import { Button } from "~/client/components/ui/button"; import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; -export function DownloadRecoveryKeyPage() { +const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE = + "Downloading the recovery key requires a local credential password. Ask an operator to run `bun run cli reset-password` for your user, then sign in with that password and try again."; + +type Props = { + hasCredentialPassword: boolean; +}; + +export function DownloadRecoveryKeyPage({ hasCredentialPassword }: Props) { const navigate = useNavigate(); const [password, setPassword] = useState(""); + const [blockedMessage, setBlockedMessage] = useState(null); const downloadResticPassword = useMutation({ ...downloadResticPasswordMutation(), @@ -28,10 +37,13 @@ export function DownloadRecoveryKeyPage() { window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000); toast.success("Recovery key downloaded successfully!"); + setBlockedMessage(null); void navigate({ to: "/volumes", replace: true }); }, onError: (error) => { - toast.error("Failed to download recovery key", { description: error.message }); + const message = parseError(error)?.message; + setBlockedMessage(message?.includes("credential password") ? message : null); + toast.error("Failed to download recovery key", { description: message }); }, }); @@ -43,6 +55,7 @@ export function DownloadRecoveryKeyPage() { return; } + setBlockedMessage(null); downloadResticPassword.mutate({ body: { password, @@ -66,27 +79,41 @@ export function DownloadRecoveryKeyPage() {
-
- - setPassword(e.target.value)} - placeholder="Enter your password" - required - disabled={downloadResticPassword.isPending} - /> -

- Enter your account password to download the recovery key -

-
+ {(!hasCredentialPassword || blockedMessage) && ( + + + Local password required + + {blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE} + + + )} + + {hasCredentialPassword && ( +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + disabled={downloadResticPassword.isPending} + /> +

+ Enter your account password to download the recovery key +

+
+ )}
- + {hasCredentialPassword && ( + + )}
diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 660a35ab..e23bba65 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -1,11 +1,21 @@ import { useMutation } from "@tanstack/react-query"; -import { Download, Fingerprint, KeyRound, User, X, Settings as SettingsIcon, Building2 } from "lucide-react"; +import { + AlertTriangle, + Download, + Fingerprint, + KeyRound, + User, + X, + Settings as SettingsIcon, + Building2, +} from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; +import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert"; import { Dialog, DialogContent, @@ -29,12 +39,17 @@ import { useTimeFormat, } from "~/client/lib/datetime"; import { logger } from "~/client/lib/logger"; +import { parseError } from "~/client/lib/errors"; import { type AppContext } from "~/context"; import { TwoFactorSection } from "../components/two-factor-section"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section"; import { OrgMembersSection } from "../components/org-members-section"; import { useOrganizationContext } from "~/client/hooks/use-org-context"; +import { cn } from "~/client/lib/utils"; + +const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE = + "Downloading the recovery key requires a local credential password. Ask an operator to run `bun run cli reset-password` for your user, then sign in with that password and try again."; type Props = { appContext: AppContext; @@ -49,6 +64,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i const [confirmPassword, setConfirmPassword] = useState(""); const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); const [downloadPassword, setDownloadPassword] = useState(""); + const [downloadBlockedMessage, setDownloadBlockedMessage] = useState(null); const [isChangingPassword, setIsChangingPassword] = useState(false); const { dateFormat, timeFormat } = useRootLoaderData(); @@ -59,6 +75,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i const { activeMember, activeOrganization } = useOrganizationContext(); const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin"; const { formatDateTime } = useTimeFormat(); + const hasCredentialPassword = appContext.user?.hasCredentialPassword !== false; const handleLogout = async () => { await authClient.signOut({ @@ -90,10 +107,13 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i toast.success("Restic password file downloaded successfully"); setDownloadDialogOpen(false); setDownloadPassword(""); + setDownloadBlockedMessage(null); }, onError: (error) => { + const message = parseError(error)?.message; + setDownloadBlockedMessage(message?.includes("credential password") ? message : null); toast.error("Failed to download Restic password", { - description: error.message, + description: message, }); }, }); @@ -145,6 +165,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i return; } + setDownloadBlockedMessage(null); downloadResticPassword.mutate({ body: { password: downloadPassword, @@ -291,7 +312,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i -
+
Change Password @@ -300,7 +325,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i Update your password to keep your account secure
- +
@@ -375,12 +400,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i Download Recovery Key - For security reasons, please enter your account password to download - the recovery key file. + {!hasCredentialPassword + ? "A local credential password is required before this recovery key can be downloaded." + : "For security reasons, please enter your account password to download the recovery key file."}
-
+ + + Local password required + + {downloadBlockedMessage ?? + RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE} + + +
Cancel - diff --git a/app/context.ts b/app/context.ts index ebdc3f23..083461eb 100644 --- a/app/context.ts +++ b/app/context.ts @@ -7,6 +7,7 @@ type User = { dateFormat: string; timeFormat: string; twoFactorEnabled?: boolean | null; + hasCredentialPassword?: boolean; role?: string | null | undefined; }; diff --git a/app/routes/(auth)/download-recovery-key.tsx b/app/routes/(auth)/download-recovery-key.tsx index 0e5f598d..27b28137 100644 --- a/app/routes/(auth)/download-recovery-key.tsx +++ b/app/routes/(auth)/download-recovery-key.tsx @@ -1,9 +1,23 @@ import { createFileRoute } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { getRequestHeaders } from "@tanstack/react-start/server"; import { DownloadRecoveryKeyPage } from "~/client/modules/auth/routes/download-recovery-key"; +import { auth } from "~/server/lib/auth"; +import { userHasCredentialPassword } from "~/server/modules/auth/helpers"; + +const getRecoveryKeyUserState = createServerFn({ method: "GET" }).handler(async () => { + const headers = getRequestHeaders(); + const session = await auth.api.getSession({ headers }); + + return { + hasCredentialPassword: session?.user ? await userHasCredentialPassword(session.user.id) : false, + }; +}); export const Route = createFileRoute("/(auth)/download-recovery-key")({ - component: DownloadRecoveryKeyPage, + component: RouteComponent, errorComponent: () =>
Failed to load recovery key
, + loader: async () => getRecoveryKeyUserState(), head: () => ({ meta: [ { title: "Zerobyte - Download Recovery Key" }, @@ -14,3 +28,9 @@ export const Route = createFileRoute("/(auth)/download-recovery-key")({ ], }), }); + +function RouteComponent() { + const { hasCredentialPassword } = Route.useLoaderData(); + + return ; +} diff --git a/app/routes/(dashboard)/route.tsx b/app/routes/(dashboard)/route.tsx index 1e845539..5e88ece6 100644 --- a/app/routes/(dashboard)/route.tsx +++ b/app/routes/(dashboard)/route.tsx @@ -7,17 +7,23 @@ import { authMiddleware } from "~/middleware/auth"; import { auth } from "~/server/lib/auth"; import { getOrganizationContext } from "~/server/lib/functions/organization-context"; import { getServerConstants } from "~/server/lib/functions/server-constants"; +import { userHasCredentialPassword } from "~/server/modules/auth/helpers"; import { authService } from "~/server/modules/auth/auth.service"; export const fetchUser = createServerFn({ method: "GET" }).handler(async () => { const headers = getRequestHeaders(); const session = await auth.api.getSession({ headers }); const hasUsers = await authService.hasUsers(); + const hasCredentialPassword = session?.user ? await userHasCredentialPassword(session.user.id) : false; const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME); const sidebarOpen = !sidebarCookie ? true : sidebarCookie === "true"; - return { user: session?.user ?? null, hasUsers, sidebarOpen }; + return { + user: session?.user ? { ...session.user, hasCredentialPassword } : null, + hasUsers, + sidebarOpen, + }; }); export const Route = createFileRoute("/(dashboard)")({ diff --git a/app/server/modules/auth/__tests__/helpers.test.ts b/app/server/modules/auth/__tests__/helpers.test.ts index cd8f86d3..23e0a500 100644 --- a/app/server/modules/auth/__tests__/helpers.test.ts +++ b/app/server/modules/auth/__tests__/helpers.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { db } from "~/server/db/db"; import { account, usersTable } from "~/server/db/schema"; import { createUser, randomId, randomSlug } from "~/test/helpers/user-org"; -import { verifyUserPassword } from "../helpers"; +import { userHasCredentialPassword, verifyUserPassword } from "../helpers"; const { verifyPassword } = vi.hoisted(() => ({ verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"), @@ -61,3 +61,31 @@ describe("verifyUserPassword", () => { expect(verifyPassword).not.toHaveBeenCalled(); }); }); + +describe("userHasCredentialPassword", () => { + beforeEach(async () => { + await db.delete(account); + await db.delete(usersTable); + }); + + test("returns true when the user has a credential account with a password", async () => { + const userId = await createUser(`${randomSlug("user")}@example.com`); + await createAccount({ userId, providerId: "credential", password: "credential-password-hash" }); + + await expect(userHasCredentialPassword(userId)).resolves.toBe(true); + }); + + test("returns false when the user only has SSO accounts", async () => { + const userId = await createUser(`${randomSlug("user")}@example.com`); + await createAccount({ userId, providerId: "oidc-acme", password: null }); + + await expect(userHasCredentialPassword(userId)).resolves.toBe(false); + }); + + test("returns false when the credential account has no password", async () => { + const userId = await createUser(`${randomSlug("user")}@example.com`); + await createAccount({ userId, providerId: "credential", password: null }); + + await expect(userHasCredentialPassword(userId)).resolves.toBe(false); + }); +}); diff --git a/app/server/modules/auth/helpers.ts b/app/server/modules/auth/helpers.ts index 6442aacd..4f440683 100644 --- a/app/server/modules/auth/helpers.ts +++ b/app/server/modules/auth/helpers.ts @@ -22,3 +22,12 @@ export const verifyUserPassword = async ({ password, userId }: PasswordVerificat return true; }; + +export const userHasCredentialPassword = async (userId: string) => { + const userAccount = await db.query.account.findFirst({ + where: { AND: [{ userId }, { providerId: "credential" }] }, + columns: { password: true }, + }); + + return Boolean(userAccount?.password); +};