fix(system): block recovery key download without credential password (#899)
This commit is contained in:
parent
4b66ad73a7
commit
c071596151
7 changed files with 166 additions and 32 deletions
|
|
@ -8,11 +8,20 @@ import { Button } from "~/client/components/ui/button";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
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 navigate = useNavigate();
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [blockedMessage, setBlockedMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const downloadResticPassword = useMutation({
|
const downloadResticPassword = useMutation({
|
||||||
...downloadResticPasswordMutation(),
|
...downloadResticPasswordMutation(),
|
||||||
|
|
@ -28,10 +37,13 @@ export function DownloadRecoveryKeyPage() {
|
||||||
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
||||||
|
|
||||||
toast.success("Recovery key downloaded successfully!");
|
toast.success("Recovery key downloaded successfully!");
|
||||||
|
setBlockedMessage(null);
|
||||||
void navigate({ to: "/volumes", replace: true });
|
void navigate({ to: "/volumes", replace: true });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBlockedMessage(null);
|
||||||
downloadResticPassword.mutate({
|
downloadResticPassword.mutate({
|
||||||
body: {
|
body: {
|
||||||
password,
|
password,
|
||||||
|
|
@ -66,27 +79,41 @@ export function DownloadRecoveryKeyPage() {
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
{(!hasCredentialPassword || blockedMessage) && (
|
||||||
<Label htmlFor="password">Confirm Your Password</Label>
|
<Alert variant="warning">
|
||||||
<Input
|
<AlertTriangle className="size-5" />
|
||||||
id="password"
|
<AlertTitle>Local password required</AlertTitle>
|
||||||
type="password"
|
<AlertDescription>
|
||||||
value={password}
|
{blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
</AlertDescription>
|
||||||
placeholder="Enter your password"
|
</Alert>
|
||||||
required
|
)}
|
||||||
disabled={downloadResticPassword.isPending}
|
|
||||||
/>
|
{hasCredentialPassword && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="space-y-2">
|
||||||
Enter your account password to download the recovery key
|
<Label htmlFor="password">Confirm Your Password</Label>
|
||||||
</p>
|
<Input
|
||||||
</div>
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
disabled={downloadResticPassword.isPending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter your account password to download the recovery key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
{hasCredentialPassword && (
|
||||||
<Download size={16} className="mr-2" />
|
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||||
Download Recovery Key
|
<Download size={16} className="mr-2" />
|
||||||
</Button>
|
Download Recovery Key
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
|
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -29,12 +39,17 @@ import {
|
||||||
useTimeFormat,
|
useTimeFormat,
|
||||||
} from "~/client/lib/datetime";
|
} from "~/client/lib/datetime";
|
||||||
import { logger } from "~/client/lib/logger";
|
import { logger } from "~/client/lib/logger";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { type AppContext } from "~/context";
|
import { type AppContext } from "~/context";
|
||||||
import { TwoFactorSection } from "../components/two-factor-section";
|
import { TwoFactorSection } from "../components/two-factor-section";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
||||||
import { OrgMembersSection } from "../components/org-members-section";
|
import { OrgMembersSection } from "../components/org-members-section";
|
||||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
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 = {
|
type Props = {
|
||||||
appContext: AppContext;
|
appContext: AppContext;
|
||||||
|
|
@ -49,6 +64,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||||
const [downloadPassword, setDownloadPassword] = useState("");
|
const [downloadPassword, setDownloadPassword] = useState("");
|
||||||
|
const [downloadBlockedMessage, setDownloadBlockedMessage] = useState<string | null>(null);
|
||||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
const { dateFormat, timeFormat } = useRootLoaderData();
|
const { dateFormat, timeFormat } = useRootLoaderData();
|
||||||
|
|
||||||
|
|
@ -59,6 +75,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
const { activeMember, activeOrganization } = useOrganizationContext();
|
const { activeMember, activeOrganization } = useOrganizationContext();
|
||||||
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
|
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
|
||||||
const { formatDateTime } = useTimeFormat();
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
const hasCredentialPassword = appContext.user?.hasCredentialPassword !== false;
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
|
|
@ -90,10 +107,13 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
toast.success("Restic password file downloaded successfully");
|
toast.success("Restic password file downloaded successfully");
|
||||||
setDownloadDialogOpen(false);
|
setDownloadDialogOpen(false);
|
||||||
setDownloadPassword("");
|
setDownloadPassword("");
|
||||||
|
setDownloadBlockedMessage(null);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
const message = parseError(error)?.message;
|
||||||
|
setDownloadBlockedMessage(message?.includes("credential password") ? message : null);
|
||||||
toast.error("Failed to download Restic password", {
|
toast.error("Failed to download Restic password", {
|
||||||
description: error.message,
|
description: message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -145,6 +165,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDownloadBlockedMessage(null);
|
||||||
downloadResticPassword.mutate({
|
downloadResticPassword.mutate({
|
||||||
body: {
|
body: {
|
||||||
password: downloadPassword,
|
password: downloadPassword,
|
||||||
|
|
@ -291,7 +312,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<div className="border-t border-border/50 bg-card-header p-6">
|
<div
|
||||||
|
className={cn("border-t border-border/50 bg-card-header p-6", {
|
||||||
|
hidden: !hasCredentialPassword,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<KeyRound className="size-5" />
|
<KeyRound className="size-5" />
|
||||||
Change Password
|
Change Password
|
||||||
|
|
@ -300,7 +325,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
Update your password to keep your account secure
|
Update your password to keep your account secure
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6">
|
<CardContent className={cn("p-6", { hidden: !hasCredentialPassword })}>
|
||||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="current-password">Current Password</Label>
|
<Label htmlFor="current-password">Current Password</Label>
|
||||||
|
|
@ -375,12 +400,26 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Download Recovery Key</DialogTitle>
|
<DialogTitle>Download Recovery Key</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
For security reasons, please enter your account password to download
|
{!hasCredentialPassword
|
||||||
the recovery key file.
|
? "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."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
className={cn({
|
||||||
|
hidden: hasCredentialPassword && !downloadBlockedMessage,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-5" />
|
||||||
|
<AlertTitle>Local password required</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{downloadBlockedMessage ??
|
||||||
|
RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className={cn("space-y-2", { hidden: !hasCredentialPassword })}>
|
||||||
<Label htmlFor="download-password">Your Password</Label>
|
<Label htmlFor="download-password">Your Password</Label>
|
||||||
<Input
|
<Input
|
||||||
id="download-password"
|
id="download-password"
|
||||||
|
|
@ -404,7 +443,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<X className="h-4 w-4 mr-2" />
|
<X className="h-4 w-4 mr-2" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={downloadResticPassword.isPending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={downloadResticPassword.isPending}
|
||||||
|
className={cn({ hidden: !hasCredentialPassword })}
|
||||||
|
>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ type User = {
|
||||||
dateFormat: string;
|
dateFormat: string;
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
twoFactorEnabled?: boolean | null;
|
twoFactorEnabled?: boolean | null;
|
||||||
|
hasCredentialPassword?: boolean;
|
||||||
role?: string | null | undefined;
|
role?: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
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 { 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")({
|
export const Route = createFileRoute("/(auth)/download-recovery-key")({
|
||||||
component: DownloadRecoveryKeyPage,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load recovery key</div>,
|
errorComponent: () => <div>Failed to load recovery key</div>,
|
||||||
|
loader: async () => getRecoveryKeyUserState(),
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Zerobyte - Download Recovery Key" },
|
{ title: "Zerobyte - Download Recovery Key" },
|
||||||
|
|
@ -14,3 +28,9 @@ export const Route = createFileRoute("/(auth)/download-recovery-key")({
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { hasCredentialPassword } = Route.useLoaderData();
|
||||||
|
|
||||||
|
return <DownloadRecoveryKeyPage hasCredentialPassword={hasCredentialPassword} />;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,23 @@ import { authMiddleware } from "~/middleware/auth";
|
||||||
import { auth } from "~/server/lib/auth";
|
import { auth } from "~/server/lib/auth";
|
||||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||||
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
||||||
|
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
|
||||||
import { authService } from "~/server/modules/auth/auth.service";
|
import { authService } from "~/server/modules/auth/auth.service";
|
||||||
|
|
||||||
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
const headers = getRequestHeaders();
|
const headers = getRequestHeaders();
|
||||||
const session = await auth.api.getSession({ headers });
|
const session = await auth.api.getSession({ headers });
|
||||||
const hasUsers = await authService.hasUsers();
|
const hasUsers = await authService.hasUsers();
|
||||||
|
const hasCredentialPassword = session?.user ? await userHasCredentialPassword(session.user.id) : false;
|
||||||
|
|
||||||
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
|
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
|
||||||
const sidebarOpen = !sidebarCookie ? true : sidebarCookie === "true";
|
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)")({
|
export const Route = createFileRoute("/(dashboard)")({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { account, usersTable } from "~/server/db/schema";
|
import { account, usersTable } from "~/server/db/schema";
|
||||||
import { createUser, randomId, randomSlug } from "~/test/helpers/user-org";
|
import { createUser, randomId, randomSlug } from "~/test/helpers/user-org";
|
||||||
import { verifyUserPassword } from "../helpers";
|
import { userHasCredentialPassword, verifyUserPassword } from "../helpers";
|
||||||
|
|
||||||
const { verifyPassword } = vi.hoisted(() => ({
|
const { verifyPassword } = vi.hoisted(() => ({
|
||||||
verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"),
|
verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"),
|
||||||
|
|
@ -61,3 +61,31 @@ describe("verifyUserPassword", () => {
|
||||||
expect(verifyPassword).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,3 +22,12 @@ export const verifyUserPassword = async ({ password, userId }: PasswordVerificat
|
||||||
|
|
||||||
return true;
|
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);
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue