Skip to content

Feature/2fa2#350

Merged
lindesvard merged 5 commits intomainfrom
feature/2fa2
Apr 24, 2026
Merged

Feature/2fa2#350
lindesvard merged 5 commits intomainfrom
feature/2fa2

Conversation

@lindesvard
Copy link
Copy Markdown
Contributor

@lindesvard lindesvard commented Apr 24, 2026

Summary by CodeRabbit

  • New Features
    • Full two-factor auth (TOTP + recovery codes): setup, enable, disable, regenerate, copy, download.
    • Account area and "Two-factor auth" tab with verification page and modals for 2FA flows.
    • Sign-in now routes to a verification step when 2FA is required; sign-up redirects to home.
    • Added ENCRYPTION_KEY config entry to .env.example for encrypting sensitive data at rest.
    • "Account" added to profile menu and "Clients / API keys" tab label updated.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds TOTP-based 2FA: crypto utilities, DB models/migration, trpc 2FA endpoints, frontend modals/pages for setup/verify/regenerate/disable, route reorganizations from profile→account, sign-in flow changes to require verification when TOTP is enabled, and a new ENCRYPTION_KEY env entry for encrypting sensitive data.

Changes

Cohort / File(s) Summary
Auth utilities & deps
packages/auth/src/totp.ts, packages/auth/src/index.ts, packages/auth/package.json
New TOTP/recovery-code utilities (generate secret, otpauth URL, QR data URL, verify codes, generate/hash/consume recovery codes). Re-exports totp and adds runtime deps @oslojs/otp, qrcode (+types).
Validation
packages/validation/src/index.ts
Adds Zod schemas zTotpCode and zTotpOrRecoveryCode with exported types.
Database schema & migration
packages/db/prisma/schema.prisma, packages/db/prisma/migrations/..._add_user_totp_and_2fa_challenge/migration.sql
Adds UserTotp and TwoFactorChallenge models/tables, relations to User, indexes, and cascade FK constraints.
trpc auth router
packages/trpc/src/routers/auth.ts
Extends auth router with sign-in TOTP flow (signInTotp) and procedures: totpStatus, totpSetup, totpEnable, totpDisable, totpRegenerateRecoveryCodes. signInEmail now issues a challenge and returns { type: 'totp_required' } when applicable.
Frontend modals & modal registry
apps/start/src/modals/setup-two-factor.tsx, .../regenerate-recovery-codes.tsx, .../disable-two-factor.tsx, apps/start/src/modals/index.tsx
Adds Setup/Regenerate/Disable 2FA modals and registers them in the modal registry.
Modal container behavior
apps/start/src/modals/Modal/Container.tsx
Tracks content ref and refines onPointerDownOutside to ignore events inside content bounds or from com-1password-button.
Sign-in / Sign-up handlers
apps/start/src/components/auth/sign-in-email-form.tsx, apps/start/src/components/auth/sign-up-email-form.tsx
Sign-in success now routes to /verify when { type: 'totp_required' }. Sign-up redirect changed from /onboarding/project to /.
Account pages & routing
apps/start/src/routes/_login.verify.tsx, apps/start/src/routes/_app.account.tsx, apps/start/src/routes/_app.$organizationId.account._tabs.*, apps/start/src/routeTree.gen.ts
Adds /­_login/verify verification page; introduces /_app/account redirect and organization-scoped /.../$organizationId/account pages; moves profile tabs to account tabs and adds Two-factor tab; regenerates route types.
Account UI & profile toggle
apps/start/src/components/profile-toggle.tsx, apps/start/src/routes/_app.$organizationId.account._tabs.index.tsx, apps/start/src/routes/_app.$organizationId.account._tabs.email-preferences.tsx, ...settings._tabs.tsx
Adds Account menu item with org-scoped routing, displays read-only email in account index, moves email-preferences route, tweaks settings tab label to "Clients / API keys", and wires form validation.
Editor & config
.env.example, .vscode/settings.json, biome.json
Adds ENCRYPTION_KEY env guidance, reformats Tailwind classRegex entries, and disables noImgElement linter rule.

Sequence Diagram(s)

sequenceDiagram
    participant User as User (Browser)
    participant FE as Frontend
    participant BE as Backend
    participant DB as Database
    participant Auth as Auth Utils

    User->>FE: Submit email sign-in
    FE->>BE: signInEmail(email,password)
    BE->>DB: fetch user and user_totp
    alt user has totp enabled
        BE->>DB: create two_factor_challenge
        BE-->>FE: { type: "totp_required" }
        FE->>User: navigate to /verify
        User->>FE: submit TOTP or recovery code
        FE->>BE: signInTotp(code)
        BE->>DB: validate challenge & user_totp
        BE->>Auth: verifyTotpCode or consumeRecoveryCode
        alt valid
            BE->>DB: delete challenge, create session
            BE-->>FE: signed in (redirect /)
        else invalid
            BE-->>FE: error
        end
    else
        BE->>DB: create session
        BE-->>FE: { type: "email" } -> redirect /
    end
Loading
sequenceDiagram
    participant User as User (Browser)
    participant FE as Frontend
    participant Modal as SetupTwoFactor Modal
    participant BE as Backend
    participant Auth as Auth Utils
    User->>FE: Click "Enable 2FA"
    FE->>Modal: open
    Modal->>BE: totpSetup()
    BE->>Auth: generateTotpSecret()
    Auth-->>BE: secret
    BE->>Auth: generateQrDataUrl(otpauthUrl)
    Auth-->>BE: qrDataUrl
    BE-->>Modal: secret + qrDataUrl
    User->>Modal: enter OTP
    Modal->>BE: totpEnable(code, secret)
    BE->>Auth: verifyTotpCode
    Auth-->>BE: valid
    BE->>Auth: generateRecoveryCodes & hash
    BE->>DB: save UserTotp (secret, hashed recovery codes, enabledAt)
    BE-->>Modal: success + recovery codes
    Modal->>User: display recovery codes (copy/download)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

  • Feature/billing #233 — Modifies the same modal registry (apps/start/src/modals/index.tsx); potential import/registry conflicts.
  • Feature/onboarding emails #277 — Also adjusts generated route declarations and organization-scoped profile/account wiring; likely overlaps with routeTree changes.

Poem

🐰 I hopped through code to spin a key,

QR blooms and secrets guard with glee,
Codes to copy, download, keep,
Tiny hops to guard your sleep,
A burrowed lock for privacy! 🔐

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title 'Feature/2fa2' is vague and does not clearly convey what the changeset accomplishes; it lacks meaningful information about the specific 2FA implementation details. Use a more descriptive title like 'Add two-factor authentication (TOTP) support' or 'Implement TOTP-based 2FA with recovery codes' to clearly summarize the main change.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/2fa2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (14)
biome.json (1)

72-72: Use Biome overrides to limit the noImgElement rule disable to specific files.

Disabling noImgElement project-wide prevents Biome from encouraging Next.js's optimized <Image> component, which provides automatic lazy loading, responsive optimization, and improved performance (LCP, bandwidth).

Instead of a global disable, consider using Biome's overrides configuration to limit this to files that genuinely require raw <img> elements (e.g., QR code generation with data URLs):

"overrides": [
  {
    "includes": ["src/path/to/qr-code-files/**"],
    "linter": {
      "rules": {
        "performance": {
          "noImgElement": "off"
        }
      }
    }
  }
]

This keeps the rule active project-wide while carving out exceptions only where needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@biome.json` at line 72, The project currently disables the noImgElement rule
globally; instead add a Biome "overrides" entry in biome.json that keeps the
global rule enabled and only sets linter.rules.performance.noImgElement to "off"
for specific file globs via the "overrides" -> "includes" array (e.g., paths
that generate QR codes or use data-URL images). Locate the "noImgElement"
setting and replace the global disable with an overrides block using the
"overrides", "includes", and "linter" keys so only targeted files get
noImgElement: "off" while the rule remains active elsewhere.
packages/auth/src/totp.ts (1)

85-103: Early-exit loop leaks timing side-channel on recovery-code position — low risk, worth noting.

consumeRecoveryCode returns as soon as verifyPasswordHash matches. Because argon2 work is significant, an attacker with precise latency measurements can (probabilistically) infer the index of the matching stored hash. The risk is bounded — the attacker still needs a valid recovery code — but for defense in depth you can continue iterating all hashes and select the match at the end.

Not a blocker; treat as optional hardening.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth/src/totp.ts` around lines 85 - 103, The loop in
consumeRecoveryCode leaks which index matched by returning immediately after
verifyPasswordHash; instead, run verifyPasswordHash against every entry (using
normalized from normalizeRecoveryCode) and record whether any matched and the
matched index (or first match) but do not early-return; after the loop construct
remaining by removing the recorded matched index (if any) and then return {
valid: Boolean(matchFound), remaining }; ensure every verifyPasswordHash call is
awaited so total timing is consistent.
apps/start/src/routes/_login.verify.tsx (1)

65-98: Consider guarding the auto-submit against rapid re-fires.

onChange triggers mutation.mutate({ code: value }) whenever the value reaches length 6. If the mutation errors and the server leaves focus on the input (e.g. user pastes a new 6-digit code, or an autofill re-fills), this fires again automatically. Adding an || mutation.isPending guard here avoids racing with an in-flight request.

♻️ Suggested change
           onChange={(value) => {
             setCode(value);
-            if (value.length === 6) {
+            if (value.length === 6 && !mutation.isPending) {
               mutation.mutate({ code: value });
             }
           }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/routes/_login.verify.tsx` around lines 65 - 98, The onChange
handler in the InputOTP block currently calls mutation.mutate({ code: value })
as soon as value.length === 6, allowing rapid re-fires; update the handler used
in the InputOTP onChange (the arrow that calls setCode and mutation.mutate) to
first check mutation.isPending and only call mutation.mutate when not pending
(e.g., guard with if (value.length === 6 && !mutation.isPending) ...), keeping
setCode behavior intact so the input still updates but preventing duplicate
in-flight submits.
apps/start/src/routes/_app.account.tsx (1)

5-12: Catch-all silently routes transient errors to onboarding.

.catch(() => []) treats every failure (network blip, auth expiration, 5xx) as "no organizations" and redirects the user to /onboarding/project. A user with existing orgs will land in onboarding until they retry. Consider letting the error propagate (TanStack Router will surface it via the nearest error boundary) or at least distinguish "empty list" from "fetch failed".

♻️ Suggested change
-    const organizations = await context.queryClient
-      .fetchQuery(
-        context.trpc.organization.list.queryOptions(undefined, {
-          staleTime: 0,
-          gcTime: 0,
-        }),
-      )
-      .catch(() => []);
+    const organizations = await context.queryClient.fetchQuery(
+      context.trpc.organization.list.queryOptions(undefined, {
+        staleTime: 0,
+        gcTime: 0,
+      }),
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/routes/_app.account.tsx` around lines 5 - 12, The current
.catch(() => []) on the fetchQuery call for organizations masks all errors
(network/auth/5xx) and treats failures as "no organizations", forcing a redirect
to onboarding; remove the silent catch and let the error propagate from
context.queryClient.fetchQuery (using
context.trpc.organization.list.queryOptions) so TanStack Router can hit the
nearest error boundary, or if you must handle it here, replace the blanket
.catch with an explicit check that distinguishes an empty list from a failed
fetch (e.g., catch and rethrow the error or return a distinct sentinel like null
and handle that case where organizations is consumed) rather than returning []
for every failure.
packages/db/prisma/migrations/20260422194907_add_user_totp_and_2fa_challenge/migration.sql (1)

15-28: Add an index on expiresAt for cleanup queries.

Cleanup jobs that delete expired challenges (DELETE FROM two_factor_challenges WHERE "expiresAt" < now()) will do a full table scan without an index. Also worth considering: no uniqueness on userId — if the app doesn't purge prior challenges before inserting a new one, stale rows will accumulate per user.

♻️ Suggested addition
 CREATE INDEX "two_factor_challenges_userId_idx" ON "public"."two_factor_challenges"("userId");
+
+-- CreateIndex
+CREATE INDEX "two_factor_challenges_expiresAt_idx" ON "public"."two_factor_challenges"("expiresAt");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/db/prisma/migrations/20260422194907_add_user_totp_and_2fa_challenge/migration.sql`
around lines 15 - 28, Add an index on two_factor_challenges.expiresAt to avoid
full table scans for cleanup queries by creating e.g. an index named
two_factor_challenges_expiresAt_idx on the expiresAt column; additionally
consider enforcing uniqueness on two_factor_challenges.userId (or add a unique
index) if the application should only ever have one active challenge per user to
prevent stale rows accumulating (refer to table two_factor_challenges and
columns expiresAt and userId when making these changes).
apps/start/src/routes/_app.$organizationId.account._tabs.tsx (1)

22-34: Optional: consolidate navigation to a single relative form.

The special case for account (absolute) followed by a relative branch for other tabs works, but you could navigate to the parent index using a relative to: '.' (or to: '..' depending on layout/file-route relationship) to keep a single router.navigate call. Not a correctness issue — just removes the branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/routes/_app`.$organizationId.account._tabs.tsx around lines 22
- 34, handleTabChange currently branches to call router.navigate with an
absolute path for the 'account' tab and a relative path for other tabs; simplify
by using a single router.navigate call and pass a relative to value (e.g., to:
'.' or to: '..' depending on your route nesting) when tabId === 'account', and
include params: { organizationId } only when needed (or merge params into the
single call) so you remove the branch while preserving navigation to the parent
account index; update the function name handleTabChange and the router.navigate
invocation accordingly.
apps/start/src/modals/disable-two-factor.tsx (1)

47-54: Consider trimming and submit-on-Enter for better UX.

code is guarded with !code.trim() but the raw value is sent in mutate({ code }). Consider trimming before submit, and wrapping the input in a <form onSubmit> so pressing Enter works the same as clicking Disable.

♻️ Proposed change
-        <Button
-          disabled={!code.trim()}
-          loading={mutation.isPending}
-          onClick={() => mutation.mutate({ code })}
-          variant="destructive"
-        >
+        <Button
+          disabled={!code.trim()}
+          loading={mutation.isPending}
+          onClick={() => mutation.mutate({ code: code.trim() })}
+          variant="destructive"
+        >
           Disable
         </Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/modals/disable-two-factor.tsx` around lines 47 - 54, The
Button currently disables using !code.trim() but sends the raw code via
mutation.mutate({ code }) and doesn’t support Enter-to-submit; update the UI to
trim the value before calling mutation.mutate (use mutation.mutate({ code:
code.trim() }) or equivalent) and wrap the input and Button in a <form> with an
onSubmit handler that prevents default and triggers the same trimmed submit
logic so pressing Enter behaves identically to clicking the Disable Button
(ensure you keep using mutation.isPending for loading and preserve the disabled
condition using !code.trim()).
packages/db/prisma/schema.prisma (1)

150-159: Add an index on expiresAt to support cleanup of expired 2FA challenges.

Expired rows are only deleted during sign-in when the cookie points at them. If a user abandons the flow (closes the tab, loses the cookie), the row lingers. Without an index on expiresAt, a scheduled cleanup (deleteMany({ where: { expiresAt: { lt: now } } })) does a full scan as the table grows.

🗄️ Proposed change
 model TwoFactorChallenge {
   id        String   `@id`
   userId    String
   user      User     `@relation`(fields: [userId], references: [id], onDelete: Cascade)
   expiresAt DateTime
   createdAt DateTime `@default`(now())

   @@index([userId])
+  @@index([expiresAt])
   @@map("two_factor_challenges")
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/prisma/schema.prisma` around lines 150 - 159, Add an index on the
expiresAt column in the TwoFactorChallenge Prisma model so cleanup queries can
use an index; update the TwoFactorChallenge model (which currently has
@@index([userId]) and @@map("two_factor_challenges")) to include an index
directive for expiresAt (e.g., add @@index([expiresAt])) so deleteMany({ where:
{ expiresAt: { lt: now } } }) does not require a full table scan.
packages/trpc/src/routers/auth.ts (3)

435-485: Consider emailing the user on 2FA disable / recovery-code regeneration.

Disabling 2FA and regenerating recovery codes are security-sensitive actions. A notification email on these events is a standard best-practice (gives the user a chance to react to a compromised session). Not a blocker — just a recommended enhancement given sendEmail is already available in this file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routers/auth.ts` around lines 435 - 485, Add notification
emails when security-sensitive actions occur: in totpDisable and
totpRegenerateRecoveryCodes, after the DB changes (after db.userTotp.delete/...
and after db.userTotp.update(...)), call the existing sendEmail helper to notify
the user's email (accessible via ctx.session.userEmail or
ctx.session.user?.email) about the action, include context (action type "2FA
disabled" or "Recovery codes regenerated", timestamp, and IP/user-agent from ctx
if available), and ensure the mutations still return their existing payloads
even if email sending fails (catch/log email errors rather than throwing).

51-52: Operational: schedule cleanup of expired TwoFactorChallenge rows.

Expired rows are only deleted when a user returns to /signInTotp with a matching cookie. Abandoned challenges (user closes the tab) accumulate indefinitely. Combined with the suggested expiresAt index (see schema.prisma), a periodic deleteMany({ where: { expiresAt: { lt: new Date() } } }) job keeps the table bounded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routers/auth.ts` around lines 51 - 52, Add a periodic
cleanup job that removes expired TwoFactorChallenge rows (using Prisma's
deleteMany with where: { expiresAt: { lt: new Date() } }) so abandoned 2FA
challenges don't accumulate; schedule it to run at a reasonable interval (e.g.,
every few minutes) from wherever background jobs run in this service and
reference the same TTL/constants (TWO_FACTOR_CHALLENGE_TTL_SECONDS,
TWO_FACTOR_COOKIE) to keep behavior consistent with the sign-in flow and the
expiresAt index in schema.prisma.

356-401: Make totpSetup idempotent for not-yet-enabled rows.

Every call unconditionally overwrites secret and recoveryCodes for the user. Combined with setup-two-factor.tsx firing the mutation on mount (which also runs twice in StrictMode), a user can end up with a QR on screen that doesn't match the persisted secret, or have the secret silently rotated when they re-open the modal with their authenticator already paired.

Suggested behavior: if a UserTotp row exists and enabledAt is null, reuse the existing secret and return a fresh QR derived from it; only generate a new secret when none exists. This makes repeated calls safe.

🛡️ Proposed change
-    const secret = generateTotpSecret();
+    const secret =
+      existing && !existing.enabledAt ? decrypt(existing.secret) : generateTotpSecret();
     const otpauthUrl = buildOtpauthUrl({
       secret,
       accountName: user.email,
     });
     const qrDataUrl = await generateQrDataUrl(otpauthUrl);

-    await db.userTotp.upsert({
-      where: { userId },
-      create: {
-        userId,
-        secret: encrypt(secret),
-        recoveryCodes: [],
-      },
-      update: {
-        secret: encrypt(secret),
-        recoveryCodes: [],
-        enabledAt: null,
-      },
-    });
+    if (!existing) {
+      await db.userTotp.create({
+        data: { userId, secret: encrypt(secret), recoveryCodes: [] },
+      });
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routers/auth.ts` around lines 356 - 401, The totpSetup
mutation currently always generates and persists a new secret
(db.userTotp.upsert), causing race/StrictMode issues; change it to be idempotent
by: when you fetch the existing row (db.userTotp.findUnique) if it exists and
existing.enabledAt is null and existing.secret is present, decrypt and reuse
that secret to build the otpauthUrl and qrDataUrl (use decrypt matching
encrypt), and avoid overwriting secret/recoveryCodes in the database; only
generate a new secret via generateTotpSecret(), encrypt(), and create the row
when no existing row exists. Update or replace the db.userTotp.upsert logic so
it only creates when missing and otherwise leaves the stored
secret/recoveryCodes intact (or only updates enabledAt), and ensure
generateQrDataUrl and buildOtpauthUrl use the reused/decrypted secret.
apps/start/src/modals/regenerate-recovery-codes.tsx (1)

47-57: Handle clipboard write failures.

navigator.clipboard.writeText returns a Promise and can reject (insecure context, permissions, some browsers). As written, the success toast fires even if the write fails, and the rejection will bubble as an unhandled promise rejection.

🛡️ Proposed fix
-          <Button
-            className="self-start"
-            onClick={() => {
-              navigator.clipboard.writeText(newCodes.join('\n'));
-              toast.success('Copied to clipboard');
-            }}
+          <Button
+            className="self-start"
+            onClick={async () => {
+              try {
+                await navigator.clipboard.writeText(newCodes.join('\n'));
+                toast.success('Copied to clipboard');
+              } catch {
+                toast.error('Could not copy to clipboard');
+              }
+            }}
             size="sm"
             variant="outline"
           >
             Copy all
           </Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/modals/regenerate-recovery-codes.tsx` around lines 47 - 57,
The onClick handler for the "Copy all" Button currently calls
navigator.clipboard.writeText(newCodes.join('\n')) and immediately shows
toast.success, which can produce unhandled rejections and false success
messages; update the onClick to handle the returned Promise from
navigator.clipboard.writeText (or first check for navigator.clipboard presence),
then show toast.success only on successful resolution and toast.error (or a
fallback alert) on rejection; reference the Button's onClick handler,
navigator.clipboard.writeText, and toast.success/toast.error when making the
change.
apps/start/src/modals/setup-two-factor.tsx (1)

162-172: Handle clipboard write failures.

Same as in regenerate-recovery-codes.tsx: navigator.clipboard.writeText returns a Promise that can reject (insecure context/permissions). As written, the success toast fires regardless, and a rejection becomes an unhandled promise rejection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/modals/setup-two-factor.tsx` around lines 162 - 172, The
onClick handler for the "Copy all" Button uses
navigator.clipboard.writeText(codes.join('\n')) but doesn't handle rejections
and always calls toast.success; change the onClick to await or chain the Promise
from navigator.clipboard.writeText, call toast.success only on resolution, and
call toast.error (or similar) on rejection to avoid unhandled promise rejections
and surface failures; update the handler referenced on the Button (onClick) and
adjust uses of toast.success/toast.error accordingly.
apps/start/src/routes/_app.$organizationId.account._tabs.two-factor.tsx (1)

33-40: Avoid non-null assertion on enabledAt.

Line 35 uses status.data.enabledAt!. Narrow based on enabledAt itself instead of the enabled flag — the non-null assertion couples this render to a backend invariant. A simple check avoids the !:

♻️ Proposed change
-        {status.data.enabled ? (
+        {status.data.enabled && status.data.enabledAt ? (
           <EnabledView
-            enabledAt={status.data.enabledAt!}
+            enabledAt={status.data.enabledAt}
             remainingRecoveryCodes={status.data.remainingRecoveryCodes}
           />
         ) : (
           <DisabledView hasEmailProvider={status.data.hasEmailProvider} />
         )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/routes/_app`.$organizationId.account._tabs.two-factor.tsx
around lines 33 - 40, The code currently forces a non-null assertion on
status.data.enabledAt in the EnabledView props; instead, change the conditional
to branch based on the presence of status.data.enabledAt (e.g.
status.data.enabledAt != null) and only render <EnabledView
enabledAt={status.data.enabledAt} .../> when enabledAt exists; keep rendering
<DisabledView hasEmailProvider={status.data.hasEmailProvider}/> otherwise, and
if EnabledView's prop type requires a non-null value, update its usage/prop type
to accept the checked value rather than using the `!` assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/start/src/modals/setup-two-factor.tsx`:
- Around line 52-55: The useEffect currently calls setupMutation.mutate() on
mount and in React 18 StrictMode this runs twice and rotates the stored TOTP
secret; prevent duplicate/rotating secrets by adding a client-side guard and/or
making the server idempotent: on the client add a ref-based guard around the
effect so setupMutation.mutate() in useEffect(...) only runs once per actual
mount (refer to useEffect and setupMutation.mutate()), and on the server change
the totpSetup handler (UserTotp upsert logic in
packages/trpc/src/routers/auth.ts / totpSetup) to be idempotent — if a
non-enabled UserTotp row already exists for the user return that existing secret
and generated QR instead of unconditionally replacing the row.

In `@apps/start/src/routes/_app`.$organizationId.account._tabs.two-factor.tsx:
- Around line 54-58: Update the copy in the paragraph element with className
"text-muted-foreground leading-normal" to soften the claim that Google/GitHub
guarantee 2FA — change wording to indicate those providers handle or support
two-factor authentication in their account/security settings and that users must
enable it there (e.g., "which handle two-factor authentication in their account
settings; enable it in your provider's security settings"). Keep the sentence
structure and styling unchanged, only replace the current text content.

In `@packages/auth/src/totp.ts`:
- Around line 77-79: The function hashRecoveryCodes currently hashes all
recovery codes concurrently using Promise.all which can spike memory due to
Argon2; change it to hash each code sequentially by iterating over codes (e.g.,
a for..of loop) and awaiting hashPassword(normalizeRecoveryCode(code)) per
iteration, collecting results into an array and returning it; keep references to
normalizeRecoveryCode and hashPassword within hashRecoveryCodes and ensure the
function remains async and returns Promise<string[]> after finishing the
sequential loop.

In `@packages/validation/src/index.ts`:
- Around line 552-556: The validator zTotpOrRecoveryCode currently trims via
.transform after .min(1), so whitespace-only inputs ("   ") pass length check
then become empty; change zTotpOrRecoveryCode to trim the input before running
the non-empty check by using Zod's preprocess (trim the string in the preprocess
step and then validate with z.string().min(1)), keeping the exported type
ITotpOrRecoveryCode unchanged; update the zTotpOrRecoveryCode declaration to use
preprocess + z.string().min(1) so whitespace-only inputs fail validation.

---

Nitpick comments:
In `@apps/start/src/modals/disable-two-factor.tsx`:
- Around line 47-54: The Button currently disables using !code.trim() but sends
the raw code via mutation.mutate({ code }) and doesn’t support Enter-to-submit;
update the UI to trim the value before calling mutation.mutate (use
mutation.mutate({ code: code.trim() }) or equivalent) and wrap the input and
Button in a <form> with an onSubmit handler that prevents default and triggers
the same trimmed submit logic so pressing Enter behaves identically to clicking
the Disable Button (ensure you keep using mutation.isPending for loading and
preserve the disabled condition using !code.trim()).

In `@apps/start/src/modals/regenerate-recovery-codes.tsx`:
- Around line 47-57: The onClick handler for the "Copy all" Button currently
calls navigator.clipboard.writeText(newCodes.join('\n')) and immediately shows
toast.success, which can produce unhandled rejections and false success
messages; update the onClick to handle the returned Promise from
navigator.clipboard.writeText (or first check for navigator.clipboard presence),
then show toast.success only on successful resolution and toast.error (or a
fallback alert) on rejection; reference the Button's onClick handler,
navigator.clipboard.writeText, and toast.success/toast.error when making the
change.

In `@apps/start/src/modals/setup-two-factor.tsx`:
- Around line 162-172: The onClick handler for the "Copy all" Button uses
navigator.clipboard.writeText(codes.join('\n')) but doesn't handle rejections
and always calls toast.success; change the onClick to await or chain the Promise
from navigator.clipboard.writeText, call toast.success only on resolution, and
call toast.error (or similar) on rejection to avoid unhandled promise rejections
and surface failures; update the handler referenced on the Button (onClick) and
adjust uses of toast.success/toast.error accordingly.

In `@apps/start/src/routes/_app`.$organizationId.account._tabs.tsx:
- Around line 22-34: handleTabChange currently branches to call router.navigate
with an absolute path for the 'account' tab and a relative path for other tabs;
simplify by using a single router.navigate call and pass a relative to value
(e.g., to: '.' or to: '..' depending on your route nesting) when tabId ===
'account', and include params: { organizationId } only when needed (or merge
params into the single call) so you remove the branch while preserving
navigation to the parent account index; update the function name handleTabChange
and the router.navigate invocation accordingly.

In `@apps/start/src/routes/_app`.$organizationId.account._tabs.two-factor.tsx:
- Around line 33-40: The code currently forces a non-null assertion on
status.data.enabledAt in the EnabledView props; instead, change the conditional
to branch based on the presence of status.data.enabledAt (e.g.
status.data.enabledAt != null) and only render <EnabledView
enabledAt={status.data.enabledAt} .../> when enabledAt exists; keep rendering
<DisabledView hasEmailProvider={status.data.hasEmailProvider}/> otherwise, and
if EnabledView's prop type requires a non-null value, update its usage/prop type
to accept the checked value rather than using the `!` assertion.

In `@apps/start/src/routes/_app.account.tsx`:
- Around line 5-12: The current .catch(() => []) on the fetchQuery call for
organizations masks all errors (network/auth/5xx) and treats failures as "no
organizations", forcing a redirect to onboarding; remove the silent catch and
let the error propagate from context.queryClient.fetchQuery (using
context.trpc.organization.list.queryOptions) so TanStack Router can hit the
nearest error boundary, or if you must handle it here, replace the blanket
.catch with an explicit check that distinguishes an empty list from a failed
fetch (e.g., catch and rethrow the error or return a distinct sentinel like null
and handle that case where organizations is consumed) rather than returning []
for every failure.

In `@apps/start/src/routes/_login.verify.tsx`:
- Around line 65-98: The onChange handler in the InputOTP block currently calls
mutation.mutate({ code: value }) as soon as value.length === 6, allowing rapid
re-fires; update the handler used in the InputOTP onChange (the arrow that calls
setCode and mutation.mutate) to first check mutation.isPending and only call
mutation.mutate when not pending (e.g., guard with if (value.length === 6 &&
!mutation.isPending) ...), keeping setCode behavior intact so the input still
updates but preventing duplicate in-flight submits.

In `@biome.json`:
- Line 72: The project currently disables the noImgElement rule globally;
instead add a Biome "overrides" entry in biome.json that keeps the global rule
enabled and only sets linter.rules.performance.noImgElement to "off" for
specific file globs via the "overrides" -> "includes" array (e.g., paths that
generate QR codes or use data-URL images). Locate the "noImgElement" setting and
replace the global disable with an overrides block using the "overrides",
"includes", and "linter" keys so only targeted files get noImgElement: "off"
while the rule remains active elsewhere.

In `@packages/auth/src/totp.ts`:
- Around line 85-103: The loop in consumeRecoveryCode leaks which index matched
by returning immediately after verifyPasswordHash; instead, run
verifyPasswordHash against every entry (using normalized from
normalizeRecoveryCode) and record whether any matched and the matched index (or
first match) but do not early-return; after the loop construct remaining by
removing the recorded matched index (if any) and then return { valid:
Boolean(matchFound), remaining }; ensure every verifyPasswordHash call is
awaited so total timing is consistent.

In
`@packages/db/prisma/migrations/20260422194907_add_user_totp_and_2fa_challenge/migration.sql`:
- Around line 15-28: Add an index on two_factor_challenges.expiresAt to avoid
full table scans for cleanup queries by creating e.g. an index named
two_factor_challenges_expiresAt_idx on the expiresAt column; additionally
consider enforcing uniqueness on two_factor_challenges.userId (or add a unique
index) if the application should only ever have one active challenge per user to
prevent stale rows accumulating (refer to table two_factor_challenges and
columns expiresAt and userId when making these changes).

In `@packages/db/prisma/schema.prisma`:
- Around line 150-159: Add an index on the expiresAt column in the
TwoFactorChallenge Prisma model so cleanup queries can use an index; update the
TwoFactorChallenge model (which currently has @@index([userId]) and
@@map("two_factor_challenges")) to include an index directive for expiresAt
(e.g., add @@index([expiresAt])) so deleteMany({ where: { expiresAt: { lt: now }
} }) does not require a full table scan.

In `@packages/trpc/src/routers/auth.ts`:
- Around line 435-485: Add notification emails when security-sensitive actions
occur: in totpDisable and totpRegenerateRecoveryCodes, after the DB changes
(after db.userTotp.delete/... and after db.userTotp.update(...)), call the
existing sendEmail helper to notify the user's email (accessible via
ctx.session.userEmail or ctx.session.user?.email) about the action, include
context (action type "2FA disabled" or "Recovery codes regenerated", timestamp,
and IP/user-agent from ctx if available), and ensure the mutations still return
their existing payloads even if email sending fails (catch/log email errors
rather than throwing).
- Around line 51-52: Add a periodic cleanup job that removes expired
TwoFactorChallenge rows (using Prisma's deleteMany with where: { expiresAt: {
lt: new Date() } }) so abandoned 2FA challenges don't accumulate; schedule it to
run at a reasonable interval (e.g., every few minutes) from wherever background
jobs run in this service and reference the same TTL/constants
(TWO_FACTOR_CHALLENGE_TTL_SECONDS, TWO_FACTOR_COOKIE) to keep behavior
consistent with the sign-in flow and the expiresAt index in schema.prisma.
- Around line 356-401: The totpSetup mutation currently always generates and
persists a new secret (db.userTotp.upsert), causing race/StrictMode issues;
change it to be idempotent by: when you fetch the existing row
(db.userTotp.findUnique) if it exists and existing.enabledAt is null and
existing.secret is present, decrypt and reuse that secret to build the
otpauthUrl and qrDataUrl (use decrypt matching encrypt), and avoid overwriting
secret/recoveryCodes in the database; only generate a new secret via
generateTotpSecret(), encrypt(), and create the row when no existing row exists.
Update or replace the db.userTotp.upsert logic so it only creates when missing
and otherwise leaves the stored secret/recoveryCodes intact (or only updates
enabledAt), and ensure generateQrDataUrl and buildOtpauthUrl use the
reused/decrypted secret.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e0601fad-47ee-453f-9190-0a9dde0df798

📥 Commits

Reviewing files that changed from the base of the PR and between 195b72c and 3ebd9fe.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (26)
  • .env.example
  • .vscode/settings.json
  • apps/start/src/components/auth/sign-in-email-form.tsx
  • apps/start/src/components/auth/sign-up-email-form.tsx
  • apps/start/src/components/profile-toggle.tsx
  • apps/start/src/modals/Modal/Container.tsx
  • apps/start/src/modals/disable-two-factor.tsx
  • apps/start/src/modals/index.tsx
  • apps/start/src/modals/regenerate-recovery-codes.tsx
  • apps/start/src/modals/setup-two-factor.tsx
  • apps/start/src/routeTree.gen.ts
  • apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx
  • apps/start/src/routes/_app.$organizationId.account._tabs.email-preferences.tsx
  • apps/start/src/routes/_app.$organizationId.account._tabs.index.tsx
  • apps/start/src/routes/_app.$organizationId.account._tabs.tsx
  • apps/start/src/routes/_app.$organizationId.account._tabs.two-factor.tsx
  • apps/start/src/routes/_app.account.tsx
  • apps/start/src/routes/_login.verify.tsx
  • biome.json
  • packages/auth/package.json
  • packages/auth/src/index.ts
  • packages/auth/src/totp.ts
  • packages/db/prisma/migrations/20260422194907_add_user_totp_and_2fa_challenge/migration.sql
  • packages/db/prisma/schema.prisma
  • packages/trpc/src/routers/auth.ts
  • packages/validation/src/index.ts

Comment on lines +52 to +55
useEffect(() => {
setupMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fire-on-mount mutation runs twice in StrictMode and rotates the stored secret on every open.

setupMutation.mutate() inside useEffect(..., []) has two issues:

  1. In React 18 StrictMode (dev) effects run twice → two concurrent totpSetup calls. Since the server handler (packages/trpc/src/routers/auth.ts) does an upsert that overwrites secret unconditionally, the QR the user actually scans can be from response A while the DB persists response B, breaking TOTP verification.
  2. Every re-mount (close & reopen the modal, or navigate back) rotates the stored secret even when the user had already scanned the previous QR.

Two ways to fix (either/both):

  • Client guard with a ref so the mutation fires exactly once per mount (handles StrictMode).
  • Make totpSetup idempotent server-side: if a non-enabled UserTotp row already exists for the user, return its existing secret + fresh QR instead of replacing the row.
🔒 Proposed client-side guard
+  const hasStartedRef = useRef(false);
   useEffect(() => {
+    if (hasStartedRef.current) return;
+    hasStartedRef.current = true;
     setupMutation.mutate();
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);

(also add useRef to the react import)

The server-side idempotency fix is the more robust one — it also covers multi-tab scenarios.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
setupMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasStartedRef = useRef(false);
useEffect(() => {
if (hasStartedRef.current) return;
hasStartedRef.current = true;
setupMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/modals/setup-two-factor.tsx` around lines 52 - 55, The
useEffect currently calls setupMutation.mutate() on mount and in React 18
StrictMode this runs twice and rotates the stored TOTP secret; prevent
duplicate/rotating secrets by adding a client-side guard and/or making the
server idempotent: on the client add a ref-based guard around the effect so
setupMutation.mutate() in useEffect(...) only runs once per actual mount (refer
to useEffect and setupMutation.mutate()), and on the server change the totpSetup
handler (UserTotp upsert logic in packages/trpc/src/routers/auth.ts / totpSetup)
to be idempotent — if a non-enabled UserTotp row already exists for the user
return that existing secret and generated QR instead of unconditionally
replacing the row.

Comment thread packages/auth/src/totp.ts
Comment on lines +77 to +79
export async function hashRecoveryCodes(codes: string[]): Promise<string[]> {
return Promise.all(codes.map((code) => hashPassword(normalizeRecoveryCode(code))));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Parallel argon2 hashing may spike memory.

Promise.all runs all recovery-code hashes concurrently. Argon2 is intentionally memory-hard, so hashing 10 codes in parallel multiplies peak memory by ~10×. On smaller instances (or during concurrent requests), this can OOM or cause contention.

Given this only runs at setup/regenerate time, sequential hashing is effectively free and much safer.

🛡️ Proposed fix — hash sequentially
 export async function hashRecoveryCodes(codes: string[]): Promise<string[]> {
-  return Promise.all(codes.map((code) => hashPassword(normalizeRecoveryCode(code))));
+  const hashes: string[] = [];
+  for (const code of codes) {
+    hashes.push(await hashPassword(normalizeRecoveryCode(code)));
+  }
+  return hashes;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function hashRecoveryCodes(codes: string[]): Promise<string[]> {
return Promise.all(codes.map((code) => hashPassword(normalizeRecoveryCode(code))));
}
export async function hashRecoveryCodes(codes: string[]): Promise<string[]> {
const hashes: string[] = [];
for (const code of codes) {
hashes.push(await hashPassword(normalizeRecoveryCode(code)));
}
return hashes;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth/src/totp.ts` around lines 77 - 79, The function
hashRecoveryCodes currently hashes all recovery codes concurrently using
Promise.all which can spike memory due to Argon2; change it to hash each code
sequentially by iterating over codes (e.g., a for..of loop) and awaiting
hashPassword(normalizeRecoveryCode(code)) per iteration, collecting results into
an array and returning it; keep references to normalizeRecoveryCode and
hashPassword within hashRecoveryCodes and ensure the function remains async and
returns Promise<string[]> after finishing the sequential loop.

Comment on lines +552 to +556
export const zTotpOrRecoveryCode = z
.string()
.min(1)
.transform((v) => v.trim());
export type ITotpOrRecoveryCode = z.infer<typeof zTotpOrRecoveryCode>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Zod v4, does a chain like z.string().min(1).transform(v => v.trim()) apply the min(1) check to the original string before trimming, or after?

💡 Result:

In Zod v4, the chain z.string.min(1).transform(v => v.trim) applies the min(1) check to the original string before the transform. Zod follows a "parse then transform" paradigm where validations like .min (refinements/checks) are executed before .transform. If the original input fails validation (e.g., " " has length 1 but min(1) passes since length >=1; empty string "" length 0 fails before transform), the transform does not run. The transform receives the validated original value and trims it for output. For input " ", min(1) passes (length=1), transform trims to "", output "". For "", min(1) fails, no transform, error on original.

Citations:


Whitespace-only input slips past min(1) in zTotpOrRecoveryCode.

In Zod v4, min(1) is checked against the original string before the .trim() transform is applied. An input of " " (whitespace only) has length ≥ 1, so it passes validation, but then trims to an empty string. Downstream code receives "" despite passing validation checks.

🛡️ Proposed fix — trim first, then enforce non-empty
 export const zTotpOrRecoveryCode = z
   .string()
-  .min(1)
-  .transform((v) => v.trim());
+  .transform((v) => v.trim())
+  .refine((v) => v.length > 0, { message: 'Code is required' });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const zTotpOrRecoveryCode = z
.string()
.min(1)
.transform((v) => v.trim());
export type ITotpOrRecoveryCode = z.infer<typeof zTotpOrRecoveryCode>;
export const zTotpOrRecoveryCode = z
.string()
.transform((v) => v.trim())
.refine((v) => v.length > 0, { message: 'Code is required' });
export type ITotpOrRecoveryCode = z.infer<typeof zTotpOrRecoveryCode>;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/validation/src/index.ts` around lines 552 - 556, The validator
zTotpOrRecoveryCode currently trims via .transform after .min(1), so
whitespace-only inputs ("   ") pass length check then become empty; change
zTotpOrRecoveryCode to trim the input before running the non-empty check by
using Zod's preprocess (trim the string in the preprocess step and then validate
with z.string().min(1)), keeping the exported type ITotpOrRecoveryCode
unchanged; update the zTotpOrRecoveryCode declaration to use preprocess +
z.string().min(1) so whitespace-only inputs fail validation.

@lindesvard lindesvard merged commit 6c34233 into main Apr 24, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant