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:
Arnaud
2026-07-13 23:08:15 +02:00
parent 4e5f45a7e5
commit acc93de708
17 changed files with 5500 additions and 13 deletions
+5
View File
@@ -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
+13 -1
View File
@@ -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}
/>
);
}
+75
View File
@@ -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>
);
}
+12 -1
View File
@@ -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>
);
}
+5 -2
View File
@@ -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 {
+15 -3
View File
@@ -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: [
+8 -1
View File
@@ -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",
+36 -1
View File
@@ -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",
+36 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.22.0",
"version": "0.23.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -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
View File
@@ -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
}
]
}
+11
View File
@@ -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" }),