feat: two-factor authentication (TOTP + backup codes)
Uses better-auth's built-in twoFactor plugin rather than hand-rolling TOTP — adds the two_factors table and users.twoFactorEnabled, wires the server/client plugins (allowPasswordless: true so OAuth-only accounts aren't locked out of managing 2FA), and adds a custom rate limit for the verify endpoints (5/min — far stricter than the default 100/10s, since a 6-digit code has a much smaller keyspace than a password). New Settings → Security section walks through enable (password confirm -> QR + backup codes -> confirm a live code before it's actually turned on, per the plugin's default flow) and disable. New /verify-2fa page handles the post-password mid-login step, with a backup-code fallback. Verified live end-to-end: enable, confirm, forced re-auth on next sign-in, TOTP accepted, backup code accepted (single-use), disable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||
|
||||
## 0.23.0 — 2026-07-13 23:06
|
||||
|
||||
### Added
|
||||
- **Two-factor authentication**: turn it on in Settings → Security to require a code from an authenticator app when signing in, with backup codes in case you lose access to it.
|
||||
|
||||
## 0.22.0 — 2026-07-13 22:49
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, accounts, users, eq, and } from "@epicure/db";
|
||||
import { SecurityForm } from "@/components/settings/security-form";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
@@ -9,5 +10,16 @@ export default async function SecurityPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
return <SecurityForm currentEmail={session.user.email} />;
|
||||
const [dbUser, credentialAccount] = await Promise.all([
|
||||
db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { twoFactorEnabled: true } }),
|
||||
db.query.accounts.findFirst({ where: and(eq(accounts.userId, session.user.id), eq(accounts.providerId, "credential")) }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SecurityForm
|
||||
currentEmail={session.user.email}
|
||||
twoFactorEnabled={dbUser?.twoFactorEnabled ?? false}
|
||||
hasPassword={!!credentialAccount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function Verify2faPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("auth");
|
||||
const [code, setCode] = useState("");
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const { error } = useBackupCode
|
||||
? await authClient.twoFactor.verifyBackupCode({ code: code.trim() })
|
||||
: await authClient.twoFactor.verifyTotp({ code: code.trim() });
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setError(error.message ?? t("twoFactorInvalidCode"));
|
||||
} else {
|
||||
router.push("/recipes");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-semibold tracking-tight">{t("twoFactorTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{useBackupCode ? t("twoFactorBackupDescription") : t("twoFactorAppDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{useBackupCode ? t("twoFactorBackupCodeLabel") : t("twoFactorCodeLabel")}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
inputMode={useBackupCode ? "text" : "numeric"}
|
||||
placeholder={useBackupCode ? undefined : "000000"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button className="w-full" type="submit" disabled={loading || !code.trim()}>
|
||||
{loading ? t("twoFactorVerifying") : t("twoFactorVerify")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</form>
|
||||
<CardFooter className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setUseBackupCode(!useBackupCode); setCode(""); setError(null); }}
|
||||
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
{useBackupCode ? t("twoFactorUseApp") : t("twoFactorUseBackupCode")}
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,17 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { TwoFactorSettings } from "@/components/settings/two-factor-settings";
|
||||
|
||||
export function SecurityForm({ currentEmail }: { currentEmail: string }) {
|
||||
export function SecurityForm({
|
||||
currentEmail,
|
||||
twoFactorEnabled,
|
||||
hasPassword,
|
||||
}: {
|
||||
currentEmail: string;
|
||||
twoFactorEnabled: boolean;
|
||||
hasPassword: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settingsForm");
|
||||
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
@@ -139,6 +148,8 @@ export function SecurityForm({ currentEmail }: { currentEmail: string }) {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<TwoFactorSettings initialEnabled={twoFactorEnabled} hasPassword={hasPassword} />
|
||||
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">{t("downloadData")}</h2>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ShieldCheck, ShieldOff, Copy, Check } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Step = "closed" | "password" | "setup" | "disable-password";
|
||||
|
||||
export function TwoFactorSettings({ initialEnabled, hasPassword }: { initialEnabled: boolean; hasPassword: boolean }) {
|
||||
const t = useTranslations("settingsForm");
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [step, setStep] = useState<Step>("closed");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [confirmCode, setConfirmCode] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function closeAndReset() {
|
||||
setStep("closed");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
setQrDataUrl(null);
|
||||
setBackupCodes([]);
|
||||
setConfirmCode("");
|
||||
setCopied(false);
|
||||
}
|
||||
|
||||
async function startEnable() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data, error } = await authClient.twoFactor.enable(
|
||||
hasPassword ? { password } : {}
|
||||
);
|
||||
if (error || !data) {
|
||||
setError(error?.message ?? t("twoFactorEnableFailed"));
|
||||
return;
|
||||
}
|
||||
const dataUrl = await QRCode.toDataURL(data.totpURI, { margin: 1, width: 200 });
|
||||
setQrDataUrl(dataUrl);
|
||||
setBackupCodes(data.backupCodes);
|
||||
setStep("setup");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmEnable() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { error } = await authClient.twoFactor.verifyTotp({ code: confirmCode.trim() });
|
||||
if (error) {
|
||||
setError(error.message ?? t("twoFactorInvalidCode"));
|
||||
return;
|
||||
}
|
||||
setEnabled(true);
|
||||
toast.success(t("twoFactorEnabled"));
|
||||
closeAndReset();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { error } = await authClient.twoFactor.disable(hasPassword ? { password } : {});
|
||||
if (error) {
|
||||
setError(error.message ?? t("twoFactorDisableFailed"));
|
||||
return;
|
||||
}
|
||||
setEnabled(false);
|
||||
toast.success(t("twoFactorDisabled"));
|
||||
closeAndReset();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyBackupCodes() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodes.join("\n"));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error(t("twoFactorCopyFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">{t("twoFactorTitle")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-prose">{t("twoFactorDescription")}</p>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStep("disable-password")}
|
||||
className="gap-1.5 shrink-0"
|
||||
>
|
||||
<ShieldOff className="h-4 w-4" />
|
||||
{t("twoFactorDisableButton")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => (hasPassword ? setStep("password") : void startEnable())}
|
||||
className="gap-1.5 shrink-0"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{t("twoFactorEnableButton")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password confirmation before enabling */}
|
||||
<Dialog open={step === "password"} onOpenChange={(open) => !open && closeAndReset()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("twoFactorConfirmPasswordTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("twoFactorConfirmPasswordDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="two-factor-password">{t("currentPassword")}</Label>
|
||||
<Input
|
||||
id="two-factor-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
|
||||
<Button onClick={() => void startEnable()} disabled={busy || !password}>
|
||||
{busy ? t("saving") : t("twoFactorContinue")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* QR + backup codes + confirmation code */}
|
||||
<Dialog open={step === "setup"} onOpenChange={(open) => !open && closeAndReset()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("twoFactorSetupTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("twoFactorSetupDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{qrDataUrl && (
|
||||
<div className="flex justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL generated client-side, not an optimizable remote image */}
|
||||
<img src={qrDataUrl} alt={t("twoFactorQrAlt")} className="h-40 w-40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("twoFactorBackupCodesLabel")}</p>
|
||||
<div className="rounded-lg border bg-muted/40 p-3 grid grid-cols-2 gap-1 font-mono text-xs">
|
||||
{backupCodes.map((c) => <span key={c}>{c}</span>)}
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" className="w-full" onClick={() => void copyBackupCodes()}>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? t("twoFactorCodesCopied") : t("twoFactorCopyCodes")}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t("twoFactorBackupCodesHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="two-factor-confirm-code">{t("twoFactorCodeLabel")}</Label>
|
||||
<Input
|
||||
id="two-factor-confirm-code"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="000000"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
|
||||
<Button onClick={() => void confirmEnable()} disabled={busy || !confirmCode.trim()}>
|
||||
{busy ? t("saving") : t("twoFactorVerifyAndEnable")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Password confirmation before disabling */}
|
||||
<Dialog open={step === "disable-password"} onOpenChange={(open) => !open && closeAndReset()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("twoFactorDisableTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("twoFactorDisableDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{hasPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="two-factor-disable-password">{t("currentPassword")}</Label>
|
||||
<Input
|
||||
id="two-factor-disable-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDisable()}
|
||||
disabled={busy || (hasPassword && !password)}
|
||||
>
|
||||
{busy ? t("saving") : t("twoFactorDisableButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
import { genericOAuthClient, twoFactorClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
plugins: [
|
||||
genericOAuthClient(),
|
||||
twoFactorClient({ twoFactorPage: "/verify-2fa" }),
|
||||
],
|
||||
});
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { genericOAuth, twoFactor } from "better-auth/plugins";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db";
|
||||
import { db, users, sessions, accounts, verifications, twoFactors, eq, count } from "@epicure/db";
|
||||
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
|
||||
import { isSignupsDisabled } from "@/lib/site-settings";
|
||||
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
||||
@@ -17,11 +17,19 @@ export const auth = betterAuth({
|
||||
// rules) — everything else on /api/auth falls back to 100/10s.
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
// A 6-digit TOTP/backup code has far fewer combinations than a password —
|
||||
// the generic 100-req/10s default is too loose to meaningfully slow down
|
||||
// guessing it.
|
||||
customRules: {
|
||||
"/two-factor/verify-totp": { window: 60, max: 5 },
|
||||
"/two-factor/verify-otp": { window: 60, max: 5 },
|
||||
"/two-factor/verify-backup-code": { window: 60, max: 5 },
|
||||
},
|
||||
},
|
||||
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: { user: users, session: sessions, account: accounts, verification: verifications },
|
||||
schema: { user: users, session: sessions, account: accounts, verification: verifications, twoFactor: twoFactors },
|
||||
}),
|
||||
|
||||
emailAndPassword: {
|
||||
@@ -68,6 +76,10 @@ export const auth = betterAuth({
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// allowPasswordless: OAuth-only accounts (no credential/password account)
|
||||
// would otherwise never be able to enable or manage 2FA, since the
|
||||
// endpoint requires a password to confirm identity by default.
|
||||
twoFactor({ issuer: "Epicure", allowPasswordless: true }),
|
||||
...(process.env["AUTHENTIK_CLIENT_ID"] && process.env["AUTHENTIK_BASE_URL"] ? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.22.0";
|
||||
export const APP_VERSION = "0.23.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.23.0",
|
||||
date: "2026-07-13 23:06",
|
||||
added: [
|
||||
"**Two-factor authentication**: turn it on in Settings → Security to require a code from an authenticator app when signing in, with backup codes in case you lose access to it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.22.0",
|
||||
date: "2026-07-13 22:49",
|
||||
|
||||
@@ -592,7 +592,17 @@
|
||||
"resendingSending": "Sending…",
|
||||
"resendFailed": "Failed to resend",
|
||||
"resendSuccess": "Verification email sent — check your inbox",
|
||||
"or": "or"
|
||||
"or": "or",
|
||||
"twoFactorTitle": "Two-factor authentication",
|
||||
"twoFactorAppDescription": "Enter the 6-digit code from your authenticator app.",
|
||||
"twoFactorBackupDescription": "Enter one of your backup codes.",
|
||||
"twoFactorCodeLabel": "Code",
|
||||
"twoFactorBackupCodeLabel": "Backup code",
|
||||
"twoFactorVerify": "Verify",
|
||||
"twoFactorVerifying": "Verifying…",
|
||||
"twoFactorInvalidCode": "Invalid code",
|
||||
"twoFactorUseBackupCode": "Use a backup code instead",
|
||||
"twoFactorUseApp": "Use your authenticator app instead"
|
||||
},
|
||||
"batchCooking": {
|
||||
"title": "Batch cooking",
|
||||
@@ -1174,6 +1184,31 @@
|
||||
"downloadData": "Download my data",
|
||||
"downloadDataDescription": "Get a JSON export of all the personal data Epicure holds about you — your profile, recipes, activity, and settings.",
|
||||
"downloadDataButton": "Download my data",
|
||||
"twoFactorTitle": "Two-factor authentication",
|
||||
"twoFactorDescription": "Require a code from an authenticator app in addition to your password when signing in.",
|
||||
"twoFactorEnableButton": "Enable",
|
||||
"twoFactorDisableButton": "Disable",
|
||||
"twoFactorEnabled": "Two-factor authentication enabled",
|
||||
"twoFactorDisabled": "Two-factor authentication disabled",
|
||||
"twoFactorEnableFailed": "Failed to start setup",
|
||||
"twoFactorDisableFailed": "Failed to disable",
|
||||
"twoFactorConfirmPasswordTitle": "Confirm your password",
|
||||
"twoFactorConfirmPasswordDescription": "Enter your password to set up two-factor authentication.",
|
||||
"twoFactorContinue": "Continue",
|
||||
"twoFactorCancel": "Cancel",
|
||||
"twoFactorSetupTitle": "Set up two-factor authentication",
|
||||
"twoFactorSetupDescription": "Scan this code with your authenticator app (like Google Authenticator or 1Password), then enter the 6-digit code it shows.",
|
||||
"twoFactorQrAlt": "QR code for two-factor authentication setup",
|
||||
"twoFactorBackupCodesLabel": "Backup codes",
|
||||
"twoFactorBackupCodesHint": "Save these somewhere safe — each one can be used once to sign in if you lose access to your authenticator app.",
|
||||
"twoFactorCopyCodes": "Copy codes",
|
||||
"twoFactorCodesCopied": "Copied",
|
||||
"twoFactorCopyFailed": "Failed to copy",
|
||||
"twoFactorCodeLabel": "6-digit code",
|
||||
"twoFactorVerifyAndEnable": "Verify & enable",
|
||||
"twoFactorInvalidCode": "Invalid code",
|
||||
"twoFactorDisableTitle": "Disable two-factor authentication",
|
||||
"twoFactorDisableDescription": "Your account will only need your password to sign in.",
|
||||
"webhookCreateFailed": "Failed to create webhook",
|
||||
"webhookDeleteFailed": "Failed to delete webhook",
|
||||
"webhookUpdateFailed": "Failed to update webhook",
|
||||
|
||||
@@ -583,7 +583,17 @@
|
||||
"checkInboxVerification": "Vérifiez votre boîte de réception pour l'e-mail de vérification.",
|
||||
"resendVerification": "Renvoyer l'e-mail de vérification",
|
||||
"resendingSending": "Envoi en cours…",
|
||||
"or": "ou"
|
||||
"or": "ou",
|
||||
"twoFactorTitle": "Authentification à deux facteurs",
|
||||
"twoFactorAppDescription": "Saisissez le code à 6 chiffres de votre application d'authentification.",
|
||||
"twoFactorBackupDescription": "Saisissez un de vos codes de secours.",
|
||||
"twoFactorCodeLabel": "Code",
|
||||
"twoFactorBackupCodeLabel": "Code de secours",
|
||||
"twoFactorVerify": "Vérifier",
|
||||
"twoFactorVerifying": "Vérification…",
|
||||
"twoFactorInvalidCode": "Code invalide",
|
||||
"twoFactorUseBackupCode": "Utiliser un code de secours",
|
||||
"twoFactorUseApp": "Utiliser votre application d'authentification"
|
||||
},
|
||||
"batchCooking": {
|
||||
"title": "Batch cooking",
|
||||
@@ -1162,6 +1172,31 @@
|
||||
"downloadData": "Télécharger mes données",
|
||||
"downloadDataDescription": "Obtenez un export JSON de toutes les données personnelles qu'Epicure détient sur vous — votre profil, vos recettes, votre activité et vos paramètres.",
|
||||
"downloadDataButton": "Télécharger mes données",
|
||||
"twoFactorTitle": "Authentification à deux facteurs",
|
||||
"twoFactorDescription": "Exiger un code d'une application d'authentification en plus de votre mot de passe pour vous connecter.",
|
||||
"twoFactorEnableButton": "Activer",
|
||||
"twoFactorDisableButton": "Désactiver",
|
||||
"twoFactorEnabled": "Authentification à deux facteurs activée",
|
||||
"twoFactorDisabled": "Authentification à deux facteurs désactivée",
|
||||
"twoFactorEnableFailed": "Échec du démarrage de la configuration",
|
||||
"twoFactorDisableFailed": "Échec de la désactivation",
|
||||
"twoFactorConfirmPasswordTitle": "Confirmez votre mot de passe",
|
||||
"twoFactorConfirmPasswordDescription": "Saisissez votre mot de passe pour configurer l'authentification à deux facteurs.",
|
||||
"twoFactorContinue": "Continuer",
|
||||
"twoFactorCancel": "Annuler",
|
||||
"twoFactorSetupTitle": "Configurer l'authentification à deux facteurs",
|
||||
"twoFactorSetupDescription": "Scannez ce code avec votre application d'authentification (comme Google Authenticator ou 1Password), puis saisissez le code à 6 chiffres qu'elle affiche.",
|
||||
"twoFactorQrAlt": "Code QR pour la configuration de l'authentification à deux facteurs",
|
||||
"twoFactorBackupCodesLabel": "Codes de secours",
|
||||
"twoFactorBackupCodesHint": "Conservez-les en lieu sûr — chacun ne peut être utilisé qu'une fois pour vous connecter si vous perdez l'accès à votre application d'authentification.",
|
||||
"twoFactorCopyCodes": "Copier les codes",
|
||||
"twoFactorCodesCopied": "Copié",
|
||||
"twoFactorCopyFailed": "Échec de la copie",
|
||||
"twoFactorCodeLabel": "Code à 6 chiffres",
|
||||
"twoFactorVerifyAndEnable": "Vérifier et activer",
|
||||
"twoFactorInvalidCode": "Code invalide",
|
||||
"twoFactorDisableTitle": "Désactiver l'authentification à deux facteurs",
|
||||
"twoFactorDisableDescription": "Votre compte n'aura besoin que de votre mot de passe pour vous connecter.",
|
||||
"webhookCreateFailed": "Échec de la création du webhook",
|
||||
"webhookDeleteFailed": "Échec de la suppression du webhook",
|
||||
"webhookUpdateFailed": "Échec de la mise à jour du webhook",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionCookie } from "better-auth/cookies";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
|
||||
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/verify-2fa", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
|
||||
const ADMIN_PATHS = ["/admin"];
|
||||
|
||||
// A public-editable shopping list's own items endpoint — no session cookie to
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "two_factors" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"secret" text NOT NULL,
|
||||
"backup_codes" text NOT NULL,
|
||||
"verified" boolean DEFAULT true NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "two_factor_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "two_factors" ADD CONSTRAINT "two_factors_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -260,6 +260,13 @@
|
||||
"when": 1783939302554,
|
||||
"tag": "0036_mixed_wither",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "7",
|
||||
"when": 1783976055649,
|
||||
"tag": "0037_big_nehzno",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export const users = pgTable("users", {
|
||||
stripeCustomerId: text("stripe_customer_id").unique(),
|
||||
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
||||
locale: text("locale").notNull().default("en"),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -71,6 +72,16 @@ export const verifications = pgTable("verifications", {
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Better Auth's twoFactor plugin table — secret/backupCodes are encrypted by
|
||||
// the plugin before storage, not plaintext.
|
||||
export const twoFactors = pgTable("two_factors", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
verified: boolean("verified").notNull().default(true),
|
||||
});
|
||||
|
||||
export const userFollows = pgTable("user_follows", {
|
||||
followerId: text("follower_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
followingId: text("following_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
Reference in New Issue
Block a user