Skip to content

Conversation

@rossmanko
Copy link
Contributor

@rossmanko rossmanko commented Sep 13, 2025

Summary by CodeRabbit

  • New Features

    • Security tab in Settings to manage MFA: enroll (QR/manual), verify codes, and remove factors.
    • “Log out of all devices” action showing revoked session count.
  • Improvements

    • Standardized JSON API responses and clearer error messages.
    • More robust session handling: immediate or progressive auto-logout on invalid/expired sessions and improved 401 handling.
  • Style

    • Dark-mode styling fix for switches.
  • Chores

    • Updated default AI model selection.

- Add immediate logout on production digest errors and repeated token failures
- Create secure MFA factor deletion with TOTP verification
- Add DeleteMfaFactorDialog component with proper validation
- Remove unused MFA challenge API endpoint
- Refactor MFA APIs to use NextResponse.json directly
- Harden logout-all route to handle already-ended sessions gracefully
- Add 401 redirect handling in entitlements API
- Strengthen ConvexClientProvider with fallback logout on repeated failures
@vercel
Copy link

vercel bot commented Sep 13, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
hackerai Ready Ready Preview Comment Sep 14, 2025 1:34pm

@coderabbitai
Copy link

coderabbitai bot commented Sep 13, 2025

Walkthrough

Refactors entitlements API to use shared JSON helpers and organization-scoped refresh; adds MFA endpoints (enroll/verify/list/delete), a logout-all endpoint, new Security UI (tabs and dialogs), client auth failure handling, a Convex token logout counter, minor provider and styling tweaks.

Changes

Cohort / File(s) Summary
API response helpers
lib/api/response.ts
New json, extractErrorMessage, and isUnauthorizedError helpers centralizing JSON responses, error normalization, and unauthorized detection.
Auth / user ID
lib/auth/get-user-id.ts
Replaced WorkOS session flow with AuthKit-based getUserIDAndPro implementation that reads entitlements from the session; signatures unchanged.
Entitlements route
app/api/entitlements/route.ts
Switches to json helper responses; adds org-scoped entitlement resolution via membership fallback; sets sealed session cookie on refresh; improves error normalization/mapping.
MFA API endpoints
app/api/mfa/enroll/route.ts, app/api/mfa/verify/route.ts, app/api/mfa/factors/route.ts, app/api/mfa/delete/route.ts
New routes: POST enroll (TOTP), POST verify, GET list factors, POST delete (with challenge verification). Consistent JSON responses and detailed error handling.
Logout-all API
app/api/logout-all/route.ts
New POST route that lists user sessions and revokes them in parallel (Promise.allSettled); returns revoked count or 500 on top-level failure.
Security UI & dialogs
app/components/SecurityTab.tsx, app/components/MfaVerificationDialog.tsx, app/components/DeleteMfaFactorDialog.tsx, app/components/SettingsDialog.tsx
Adds Security tab to Settings, SecurityTab component managing MFA and logout flows, plus two dialogs for enrollment verification and factor deletion, wired to new APIs.
Client auth flow changes
app/contexts/GlobalState.tsx, components/ConvexClientProvider.tsx
GlobalState: redirect to /logout on 401 during entitlements fetch. ConvexClientProvider: adds failure counters, error normalization, and progressive/instant logout triggers on specific error patterns.
Provider config
lib/ai/providers.ts
Changes DEFAULT ask-model fallback to deepseek/deepseek-chat-v3-0324.
UI styling tweak
components/ui/switch.tsx
Adds dark-mode checked-state color class (dark:data-[state=checked]:bg-blue-400) for the Switch component.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant UI as SecurityTab / SettingsDialog
  participant EnrollAPI as POST /api/mfa/enroll
  participant VerifyAPI as POST /api/mfa/verify
  participant WorkOS as WorkOS

  User->>UI: Enable MFA
  UI->>EnrollAPI: POST enroll (userId)
  EnrollAPI->>WorkOS: enrollAuthFactor (TOTP)
  WorkOS-->>EnrollAPI: { factor, challenge }
  EnrollAPI-->>UI: { factor, challenge }
  UI->>User: Show QR/manual + code input
  User->>UI: Enter 6-digit code
  UI->>VerifyAPI: POST verify { challengeId, code }
  VerifyAPI->>WorkOS: verifyChallenge
  WorkOS-->>VerifyAPI: { valid, challenge }
  VerifyAPI-->>UI: { valid }
  UI->>UI: Close dialog, refresh factors
Loading
sequenceDiagram
  autonumber
  actor User
  participant UI as SecurityTab
  participant DeleteAPI as POST /api/mfa/delete
  participant FactorsAPI as GET /api/mfa/factors
  participant WorkOS as WorkOS

  User->>UI: Delete MFA factor
  UI->>DeleteAPI: POST { factorId, code }
  DeleteAPI->>WorkOS: createChallenge -> verifyChallenge -> deleteFactor
  WorkOS-->>DeleteAPI: success
  DeleteAPI-->>UI: { success }
  UI->>FactorsAPI: GET
  FactorsAPI->>WorkOS: listAuthFactors(userId)
  WorkOS-->>FactorsAPI: factors
  FactorsAPI-->>UI: { factors }
Loading
sequenceDiagram
  autonumber
  actor User
  participant UI as SecurityTab
  participant LogoutAllAPI as POST /api/logout-all
  participant WorkOS as WorkOS

  User->>UI: Log out of all devices
  UI->>LogoutAllAPI: POST
  LogoutAllAPI->>WorkOS: listSessions(userId)
  loop sessions
    LogoutAllAPI->>WorkOS: revokeSession(sessionId)
    WorkOS-->>LogoutAllAPI: ok / already-ended
  end
  LogoutAllAPI-->>UI: { revokedSessions }
  UI->>User: show toast + redirect to /logout
Loading
sequenceDiagram
  autonumber
  participant App as GlobalState / ConvexClientProvider
  participant EntAPI as GET /api/entitlements
  participant Auth as AuthKit / WorkOS
  participant Browser as window

  App->>EntAPI: GET entitlements
  EntAPI->>Auth: validate/refresh session (org-scoped)
  alt valid
    Auth-->>EntAPI: entitlements
    EntAPI-->>App: { entitlements, hasProPlan }
  else unauthorized / invalid_grant
    EntAPI-->>App: 401 / error digest
    App->>Browser: redirect /logout
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

Possibly related PRs

  • Daily branch 2025 08 29 #16 — Also modifies app/api/entitlements/route.ts for org-scoped session handling and related error mapping.
  • Daily branch 2025 09 02 #19 — Overlaps with Convex token refresh/logout handling in components/ConvexClientProvider.tsx and entitlements integration.
  • Daily branch 2025 09 05 #21 — Matches the lib/ai/providers.ts change setting the ask-model fallback to deepseek/deepseek-chat-v3-0324.

Poem

A rabbit taps the QR beneath the moon,
Six digits hum, a tiny security tune.
I hop through sessions, revoke with a cheer,
Factors fall away, the path is clear.
Entitlements checked — bedtime is near. 🐇🔐

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Feat/settings security tab" concisely and accurately captures the primary change in this changeset — adding a Security tab to Settings (and related MFA/session UI and API work) — and is short and specific enough for a teammate scanning PR history to understand the main intent.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/settings-security-tab

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


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

Copy link

@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: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/contexts/GlobalState.tsx (1)

50-53: Use Id<'chats'> instead of string for chat IDs (per coding guidelines).

-import type { Doc } from "@/convex/_generated/dataModel";
+import type { Doc, Id } from "@/convex/_generated/dataModel";
...
-  currentChatId: string | null;
-  setCurrentChatId: (chatId: string | null) => void;
+  currentChatId: Id<"chats"> | null;
+  setCurrentChatId: (chatId: Id<"chats"> | null) => void;
...
-  initializeChat: (chatId: string, fromRoute?: boolean) => void;
+  initializeChat: (chatId: Id<"chats">, fromRoute?: boolean) => void;
...
-  const initializeChat = useCallback((chatId: string, _fromRoute?: boolean) => {
+  const initializeChat = useCallback((chatId: Id<"chats">, _fromRoute?: boolean) => {
...
-  const activateChat = useCallback((chatId: string) => {
+  const activateChat = useCallback((chatId: Id<"chats">) => {

Also applies to: 98-101, 275-279

🧹 Nitpick comments (34)
lib/ai/providers.ts (1)

39-55: Tighten the tracked models type (avoid any).

Use the provider’s model type instead of Record<string, any> to keep type safety.

-  const trackedModels: Record<string, any> = {};
+  const trackedModels: Record<string, (typeof baseProviders)[keyof typeof baseProviders]> = {};
app/contexts/GlobalState.tsx (2)

186-195: Move clearTimeout to finally to avoid timer leaks on thrown/aborted fetch.

-        const response = await fetch("/api/entitlements", {
-          credentials: "include",
-          signal: controller.signal,
-        });
-
-        clearTimeout(timeoutId);
+        const response = await fetch("/api/entitlements", {
+          credentials: "include",
+          signal: controller.signal,
+        });

And in the existing finally block:

   } finally {
+    clearTimeout(timeoutId);
     setIsCheckingProPlan(false);
     // Remove the refresh param to avoid repeated refreshes

206-211: Don’t downgrade pro state on transient/network failures.

Instead of forcing hasProPlan to false on non-OK or catch, consider preserving prior state to reduce UI flicker.

-        } else {
+        } else {
           if (response.status === 401) {
             if (typeof window !== "undefined") {
               window.location.href = "/logout";
               return;
             }
           }
-          setHasProPlan(false);
+          // Preserve prior state on non-401 failures
         }
       } catch {
-        setHasProPlan(false);
+        // Preserve prior state on transient errors
lib/api/response.ts (2)

3-10: Clarify whether Cache-Control should be enforced or overridable.

Currently, init.headers override the default. If you want to always prevent caching, put "Cache-Control": "no-store" last.

-  NextResponse.json(data, {
-    ...init,
-    headers: {
-      "Cache-Control": "no-store",
-      ...(init?.headers || {}),
-    },
-  });
+  NextResponse.json(data, {
+    ...init,
+    headers: {
+      ...(init?.headers || {}),
+      "Cache-Control": "no-store",
+    },
+  });

21-29: Deduplicate unauthorized detection across server and client.

isUnauthorizedError is needed in the client (ConvexClientProvider) too; consider moving it to a server-agnostic module (e.g., lib/errors.ts) and reusing it.

Additional file (new):

// lib/errors.ts
export const extractErrorMessage = (err: unknown): string => {
  if (typeof err === "string") return err;
  if (err && typeof err === "object" && "message" in err) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return (err as any).message ?? "";
  }
  return "";
};
export const isUnauthorizedError = (err: unknown): boolean => {
  const normalized = extractErrorMessage(err).toLowerCase();
  return (
    normalized.includes("invalid_grant") ||
    normalized.includes("session has already ended") ||
    normalized.includes("no session cookie") ||
    normalized.includes("unauthorized")
  );
};

Then here keep only json or re-export from lib/errors to avoid duplicating logic.

components/ui/switch.tsx (1)

16-16: Dark checked state looks fine; verify contrast.

Ensure dark:bg-blue-400 meets contrast for the thumb/track in dark mode.

components/ConvexClientProvider.tsx (3)

20-23: Guard missing Convex URL at runtime.

Fail fast with a clear error if NEXT_PUBLIC_CONVEX_URL is unset.

-  const [convex] = useState(() => {
-    return new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!, {
+  const [convex] = useState(() => {
+    const url = process.env.NEXT_PUBLIC_CONVEX_URL;
+    if (!url) throw new Error("NEXT_PUBLIC_CONVEX_URL is not set");
+    return new ConvexReactClient(url, {
       expectAuth,
     });
   });

76-90: Fix boolean logic and make the failure window explicit.

Avoid && returning a number; compute a strict boolean and keep the window constant named.

-        const now = Date.now();
-        const withinWindow = lastFailureAtRef.current && now - lastFailureAtRef.current < 10000; // 10s
+        const now = Date.now();
+        const WINDOW_MS = 10_000;
+        const withinWindow =
+          lastFailureAtRef.current !== null &&
+          now - lastFailureAtRef.current < WINDOW_MS;

39-53: Reuse shared unauthorized check instead of duplicating shouldForceLogout.

Import a shared isUnauthorizedError (see suggestion in lib/api/response.ts) for consistency.

-  const shouldForceLogout = (value: unknown): boolean => {
-    const message =
-      typeof value === "string"
-        ? value
-        : value && typeof value === "object" && "message" in value
-          ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
-            (value as any).message
-          : undefined;
-    if (!message) return false;
-    const normalized = String(message).toLowerCase();
-    if (normalized.includes("invalid_grant")) return true;
-    if (normalized.includes("session has already ended")) return true;
-    if (normalized.includes("failed to refresh session")) return true;
-    return false;
-  };
+  // import { isUnauthorizedError } from "@/lib/errors";

And in the catch:

-        if ((shouldForceLogout(error) || hasDigest) && typeof window !== "undefined") {
+        if ((isUnauthorizedError(error) || hasDigest) && typeof window !== "undefined") {
app/api/entitlements/route.ts (4)

83-91: Deduplicate error mapping using shared isUnauthorizedError()

Leverage the existing helper instead of re‑implementing string checks here.

-    const normalized = extractErrorMessage(error).toLowerCase();
-    const should401 =
-      normalized.includes("invalid_grant") ||
-      normalized.includes("session has already ended");
-
-    return json(
-      { error: should401 ? "Unauthorized" : "Failed to refresh session" },
-      { status: should401 ? 401 : 500 },
-    );
+    const should401 = (await import("@/lib/api/response")).isUnauthorizedError(error);
+    return json(
+      { error: should401 ? "Unauthorized" : "Failed to refresh session" },
+      { status: should401 ? 401 : 500 },
+    );

27-57: Short‑circuit unauthenticated sessions before refresh

If authenticate() returns unauthenticated, return 401 early instead of relying on refresh() to fail.

-    if (authResult.authenticated) {
+    if (!authResult.authenticated) {
+      return json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    if (authResult.authenticated) {

64-70: Avoid any; type the refresh result minimally

Remove any casts and harden entitlements typing.

-    const { sealedSession, entitlements } = refreshResult as any;
-
-    const hasProPlan = (entitlements || []).includes("pro-monthly-plan");
-
-    // Create response with entitlements
-    const response = json({ entitlements: entitlements || [], hasProPlan });
+    type RefreshResult = { sealedSession?: string; entitlements?: Array<string> };
+    const { sealedSession, entitlements }: RefreshResult = refreshResult as RefreshResult;
+    const entitlementsList = Array.isArray(entitlements) ? entitlements : [];
+    const hasProPlan = entitlementsList.includes("pro-monthly-plan");
+    const response = json({ entitlements: entitlementsList, hasProPlan });

73-78: Set cookie path for consistent scope

Explicitly set path: "/" to ensure the cookie is visible across routes.

       response.cookies.set("wos-session", sealedSession, {
         httpOnly: true,
         sameSite: "lax",
         secure: true,
+        path: "/",
       });
app/components/SettingsDialog.tsx (2)

21-22: Strongly type tabs and activeTab (use union and as const)

Prevents typos, improves DX, and aligns with guidelines for discriminated unions and explicit generics.

-  const [activeTab, setActiveTab] = useState("Personalization");
+  const TABS = [
+    { id: "Personalization", label: "Personalization", icon: Settings },
+    { id: "Security", label: "Security", icon: Shield },
+  ] as const;
+  type TabId = typeof TABS[number]["id"];
+  const [activeTab, setActiveTab] = useState<TabId>(TABS[0].id);
@@
-  const tabs = [
-    { id: "Personalization", label: "Personalization", icon: Settings },
-    { id: "Security", label: "Security", icon: Shield },
-  ];
+  const tabs = TABS;
@@
-                    {tabs.map((tab) => {
+                    {tabs.map((tab: typeof TABS[number]) => {

Also applies to: 34-37, 88-109


75-113: Add ARIA roles/attributes for tabs

Improve accessibility by using role="tablist"/"tab" and aria-selected/aria-controls.

-            {/* Tabs */}
+            {/* Tabs */}
             <div
               className={`${isMobile ? "overflow-x-auto md:overflow-x-visible border-r pb-2 md:pb-0 relative" : "md:w-[221px] border-r"}`}
             >
@@
-                <div className="flex-1 flex-shrink-0 flex items-start self-stretch px-3 overflow-auto w-max md:w-full pb-0 border-b md:border-b-0 md:flex-col md:gap-3 md:px-2 max-md:gap-2.5">
+                <div role="tablist" aria-orientation={isMobile ? "horizontal" : "vertical"} className="flex-1 flex-shrink-0 flex items-start self-stretch px-3 overflow-auto w-max md:w-full pb-0 border-b md:border-b-0 md:flex-col md:gap-3 md:px-2 max-md:gap-2.5">
@@
-                        <button
+                        <button
+                          role="tab"
+                          aria-selected={activeTab === tab.id}
+                          aria-controls={`panel-${tab.id}`}
                           key={tab.id}
                           type="button"
                           onClick={() => setActiveTab(tab.id)}
@@
-              <div className="flex-1 self-stretch items-start overflow-y-auto px-4 pt-4 pb-4 md:px-6 md:pt-4">
+              <div className="flex-1 self-stretch items-start overflow-y-auto px-4 pt-4 pb-4 md:px-6 md:pt-4">
                 {activeTab === "Personalization" && (
-                  <div className="space-y-6">
+                  <div id="panel-Personalization" role="tabpanel" className="space-y-6">
@@
-                {activeTab === "Security" && <SecurityTab />}
+                {activeTab === "Security" && (
+                  <div id="panel-Security" role="tabpanel">
+                    <SecurityTab />
+                  </div>
+                )}

Also applies to: 121-189

app/api/mfa/delete/route.ts (2)

35-37: Validate 6‑digit numeric OTP (not just length)

Prevent non‑numeric codes from hitting WorkOS.

-    if (code.length !== 6) {
-      return NextResponse.json({ error: "Invalid code" }, { status: 400 });
-    }
+    if (!/^\d{6}$/.test(code)) {
+      return NextResponse.json({ error: "Invalid code" }, { status: 400 });
+    }

1-19: Standardize responses via json() helper

Other routes (e.g., entitlements) use json() to set no‑store. Mirror that here for consistency.

-import { NextRequest, NextResponse } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
+import { json } from "@/lib/api/response";
@@
-      return NextResponse.json({ error: status === 401 ? "Unauthorized" : "Failed to remove MFA factor" }, { status });
+      return json({ error: status === 401 ? "Unauthorized" : "Failed to remove MFA factor" }, { status });
@@
-      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+      return json({ error: "Unauthorized" }, { status: 401 });
@@
-      return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+      return json({ error: "Invalid JSON" }, { status: 400 });
@@
-      return NextResponse.json({ error: "factorId and code are required" }, { status: 400 });
+      return json({ error: "factorId and code are required" }, { status: 400 });
@@
-      return NextResponse.json({ error: "Invalid code" }, { status: 400 });
+      return json({ error: "Invalid code" }, { status: 400 });
@@
-      return NextResponse.json({ error: "Factor not found" }, { status: 404 });
+      return json({ error: "Factor not found" }, { status: 404 });
@@
-      return NextResponse.json({ error: "Invalid verification code" }, { status: 400 });
+      return json({ error: "Invalid verification code" }, { status: 400 });
@@
-    return NextResponse.json({ success: true });
+    return json({ success: true });
@@
-        return NextResponse.json({ error: "Challenge has expired" }, { status: 400 });
+        return json({ error: "Challenge has expired" }, { status: 400 });
@@
-    return NextResponse.json(
+    return json(
       { error: status === 401 ? "Unauthorized" : "Failed to remove MFA factor" },
       { status },
     );

Also applies to: 63-74

app/api/mfa/factors/route.ts (2)

1-15: Standardize responses via json() helper

Use the shared helper for GET/DELETE responses for consistent no‑store caching.

Example changes:

-import { NextRequest, NextResponse } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
+import { json } from "@/lib/api/response";
@@
-      return NextResponse.json({ error: status === 401 ? "Unauthorized" : "Failed to get MFA factors" }, { status });
+      return json({ error: status === 401 ? "Unauthorized" : "Failed to get MFA factors" }, { status });
@@
-    return NextResponse.json({
+    return json({
       factors: transformedFactors,
     });
@@
-    return NextResponse.json(
+    return json(
       { error: status === 401 ? "Unauthorized" : "Failed to get MFA factors" },
       { status },
     );

Also applies to: 32-35, 38-41


22-31: PII surface check: confirm necessity of issuer/user fields

Ensure returning issuer and user is necessary for the UI; otherwise, omit to minimize exposure.

app/api/mfa/enroll/route.ts (1)

1-4: Standardize responses via json() helper

Align with lib/api/response.json to set Cache-Control: no-store automatically.

-import { NextRequest, NextResponse } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
+import { json } from "@/lib/api/response";
@@
-      return NextResponse.json({ error: status === 401 ? "Unauthorized" : "Failed to enroll MFA factor" }, { status });
+      return json({ error: status === 401 ? "Unauthorized" : "Failed to enroll MFA factor" }, { status });
@@
-      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+      return json({ error: "Unauthorized" }, { status: 401 });
@@
-    return NextResponse.json({
+    return json({
       factor: {
         id: result.authenticationFactor.id,
         type: result.authenticationFactor.type,
         qrCode: result.authenticationFactor.totp?.qrCode,
         secret: result.authenticationFactor.totp?.secret,
         issuer: result.authenticationFactor.totp?.issuer,
         user: result.authenticationFactor.totp?.user,
       },
       challenge: {
         id: result.authenticationChallenge.id,
         expiresAt: result.authenticationChallenge.expiresAt,
       },
     });
@@
-    return NextResponse.json(
+    return json(
       { error: status === 401 ? "Unauthorized" : "Failed to enroll MFA factor" },
       { status },
     );

Also applies to: 14-19, 28-43, 45-49

app/api/logout-all/route.ts (1)

36-36: Consider using Promise.all instead of Promise.allSettled for better error propagation

Using Promise.allSettled will silently ignore errors that aren't caught in the individual promises. Since you're already handling the specific errors you want to ignore, Promise.all would be more appropriate to ensure any unexpected errors are properly propagated.

-    await Promise.allSettled(revokePromises);
+    await Promise.all(revokePromises);
app/api/mfa/verify/route.ts (2)

9-19: Simplify error handling for getUserID

The nested try-catch and subsequent null check for userId are redundant since getUserID throws on failure.

-    // Get authenticated user
-    let userId: string;
-    try {
-      userId = await getUserID(req);
-    } catch (e) {
-      const status = isUnauthorizedError(e) ? 401 : 500;
-      return NextResponse.json({ error: status === 401 ? "Unauthorized" : "Failed to verify MFA challenge" }, { status });
-    }
-
-    if (!userId) {
-      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-    }
+    // Get authenticated user
+    let userId: string;
+    try {
+      userId = await getUserID(req);
+    } catch (e) {
+      const status = isUnauthorizedError(e) ? 401 : 500;
+      return NextResponse.json({ error: status === 401 ? "Unauthorized" : "Failed to verify MFA challenge" }, { status });
+    }

51-64: Use WorkOS MFA error codes instead of string-matching error messages

WorkOS returns explicit MFA error codes (e.g. authentication_challenge_previously_verified, authentication_challenge_expired) — check those instead of doing error.message.includes(...). (workos.com)

  • Replace the message-based checks in app/api/mfa/verify/route.ts (lines 51–64) to inspect error.code (or error.response?.data?.code if the SDK wraps the API response); apply the same change in app/api/mfa/delete/route.ts where message-matching is used.
app/components/DeleteMfaFactorDialog.tsx (3)

85-85: Add pattern attribute for better mobile UX

Adding a pattern attribute will improve the mobile experience by ensuring the numeric keyboard shows the correct layout.

-              onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
+              onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
+              pattern="[0-9]*"

59-61: Consider using router.push instead of window.location.href

Using Next.js router would provide better navigation handling and maintain SPA behavior.

Add at the top of the component:

import { useRouter } from "next/navigation";

Inside the component:

const router = useRouter();

Then apply this diff:

-      if (response.status === 401) {
-        window.location.href = "/logout";
-      }
+      if (response.status === 401) {
+        router.push("/logout");
+      }

70-70: Fix dialog onOpenChange handler

The Dialog's onOpenChange prop should receive the function directly, not call handleClose.

-    <Dialog open={open} onOpenChange={handleClose}>
+    <Dialog open={open} onOpenChange={onOpenChange}>
app/components/MfaVerificationDialog.tsx (4)

47-50: Validate code length consistently

The validation checks for trimmed code but doesn't validate the length to be exactly 6 characters like other validations in the codebase.

-    if (!enrollmentData?.challenge.id || !verificationCode.trim()) {
-      toast.error("Please enter the verification code");
+    if (!enrollmentData?.challenge.id || verificationCode.trim().length !== 6) {
+      toast.error("Please enter a valid 6-digit verification code");

166-167: Add input restrictions for verification code

The input should restrict to numeric values only, similar to DeleteMfaFactorDialog.

                 value={verificationCode}
-                onChange={(e) => setVerificationCode(e.target.value)}
+                onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, ""))}
+                inputMode="numeric"
+                pattern="[0-9]*"

133-137: Add success feedback for copy action

When copying the secret code, users should receive feedback that the copy was successful.

                      onClick={() => {
                        if (enrollmentData?.factor.secret) {
                          navigator.clipboard.writeText(
                            enrollmentData.factor.secret,
                          );
+                         toast.success("Code copied to clipboard");
                        }
                      }}

74-74: Avoid catching errors without proper type checking

The catch block catches any error but doesn't handle it specifically. Consider logging for debugging.

-    } catch (error) {
+    } catch (error) {
+      console.error("MFA verification error:", error);
       toast.error("Failed to verify code");
app/components/SecurityTab.tsx (4)

113-114: Use Next.js router for consistent navigation

Using window.location.href for navigation bypasses Next.js routing. Consider using the Next.js router for better SPA behavior.

Add at the top:

import { useRouter } from "next/navigation";

In the component:

const router = useRouter();

Then update both logout handlers:

-      window.location.href = "/logout";
+      await router.push("/logout");

140-142: Remove unused formatDate function

The formatDate function is defined but never used in the component.

-  const formatDate = (dateString: string) => {
-    return new Date(dateString).toLocaleDateString();
-  };

10-16: Type document IDs using Id helper type

According to the coding guidelines, document IDs should be typed using Id<'table'> instead of string.

 interface MfaFactor {
-  id: string;
+  id: Id<'mfa_factors'>;
   issuer?: string;
   user?: string;
   createdAt: string;
   updatedAt: string;
 }

Note: Adjust the table name ('mfa_factors') based on your actual Convex data model.


33-33: Explicitly type the array declaration

According to the coding guidelines, arrays should be declared with explicit generic types.

-  const [factors, setFactors] = useState<MfaFactor[]>([]);
+  const [factors, setFactors] = useState<Array<MfaFactor>>([]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a8be8d5 and 8858109.

📒 Files selected for processing (16)
  • app/api/entitlements/route.ts (4 hunks)
  • app/api/logout-all/route.ts (1 hunks)
  • app/api/mfa/delete/route.ts (1 hunks)
  • app/api/mfa/enroll/route.ts (1 hunks)
  • app/api/mfa/factors/route.ts (1 hunks)
  • app/api/mfa/verify/route.ts (1 hunks)
  • app/components/DeleteMfaFactorDialog.tsx (1 hunks)
  • app/components/MfaVerificationDialog.tsx (1 hunks)
  • app/components/SecurityTab.tsx (1 hunks)
  • app/components/SettingsDialog.tsx (3 hunks)
  • app/contexts/GlobalState.tsx (1 hunks)
  • components/ConvexClientProvider.tsx (3 hunks)
  • components/ui/switch.tsx (1 hunks)
  • lib/ai/providers.ts (1 hunks)
  • lib/api/response.ts (1 hunks)
  • lib/auth/get-user-id.ts (0 hunks)
💤 Files with no reviewable changes (1)
  • lib/auth/get-user-id.ts
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/convex_rules.mdc)

**/*.{ts,tsx}: Use Id helper type from ./_generated/dataModel to type document IDs (e.g., Id<'users'>) instead of string
When defining Record types, specify key and value types matching validators (e.g., Record<Id<'users'>, string>)
Be strict with types for document IDs; prefer Id<'table'> over string in function args and variables
Use as const for string literals in discriminated unions
Declare arrays with explicit generic type: const arr: Array = [...]
Declare records with explicit generic types: const record: Record<KeyType, ValueType> = {...}

Files:

  • app/api/mfa/verify/route.ts
  • app/api/mfa/delete/route.ts
  • lib/api/response.ts
  • app/components/SecurityTab.tsx
  • app/components/DeleteMfaFactorDialog.tsx
  • app/components/SettingsDialog.tsx
  • app/api/logout-all/route.ts
  • app/api/mfa/enroll/route.ts
  • components/ui/switch.tsx
  • app/contexts/GlobalState.tsx
  • components/ConvexClientProvider.tsx
  • app/components/MfaVerificationDialog.tsx
  • lib/ai/providers.ts
  • app/api/entitlements/route.ts
  • app/api/mfa/factors/route.ts
🧬 Code graph analysis (8)
app/api/mfa/verify/route.ts (3)
lib/auth/get-user-id.ts (1)
  • getUserID (13-31)
lib/api/response.ts (1)
  • isUnauthorizedError (21-29)
app/api/workos.ts (1)
  • workos (7-7)
app/api/mfa/delete/route.ts (4)
app/api/mfa/enroll/route.ts (1)
  • POST (6-51)
app/api/mfa/verify/route.ts (1)
  • POST (6-72)
lib/auth/get-user-id.ts (1)
  • getUserID (13-31)
lib/api/response.ts (1)
  • isUnauthorizedError (21-29)
app/components/DeleteMfaFactorDialog.tsx (4)
components/ui/dialog.tsx (4)
  • Dialog (133-133)
  • DialogContent (135-135)
  • DialogTitle (141-141)
  • DialogDescription (136-136)
components/ui/label.tsx (1)
  • Label (26-26)
components/ui/input.tsx (1)
  • Input (21-21)
components/ui/button.tsx (1)
  • Button (59-59)
app/components/SettingsDialog.tsx (1)
app/components/SecurityTab.tsx (1)
  • SecurityTab (235-235)
app/api/logout-all/route.ts (2)
lib/auth/get-user-id.ts (1)
  • getUserID (13-31)
app/api/workos.ts (1)
  • workos (7-7)
app/api/mfa/enroll/route.ts (2)
lib/auth/get-user-id.ts (1)
  • getUserID (13-31)
lib/api/response.ts (1)
  • isUnauthorizedError (21-29)
app/api/entitlements/route.ts (1)
lib/api/response.ts (2)
  • json (3-10)
  • extractErrorMessage (12-19)
app/api/mfa/factors/route.ts (2)
lib/auth/get-user-id.ts (1)
  • getUserID (13-31)
lib/api/response.ts (1)
  • isUnauthorizedError (21-29)
🔇 Additional comments (3)
app/contexts/GlobalState.tsx (1)

200-205: LGTM on 401 redirect path.

Early return avoids incorrectly flipping hasProPlan to false; finally will still clean URL/search params.

lib/ai/providers.ts (1)

8-9: Confirm OpenRouter availability for the default model slug

Web checks show deepseek/deepseek-chat-v3-0324 is a real DeepSeek release but OpenRouter lists deepseek-ai/deepseek-chat-v3-0324 as unavailable — verify this slug is enabled for your OpenRouter org before merging. If unavailable, update the default in lib/ai/providers.ts (lines 8–9) to an OpenRouter-accessible slug (e.g., the canonical deepseek-chat or your host's supported DeepSeek V3).

app/api/logout-all/route.ts (1)

11-11: Verified — workos.userManagement.listSessions(userId) is correct. Matches WorkOS Sessions docs and Node example (accepts a user ID, returns a paginated list of session objects).

Copy link

@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: 3

♻️ Duplicate comments (2)
app/api/mfa/factors/route.ts (1)

1-48: Confirm DELETE is removed/disabled on this route.

Prior feedback flagged an unsafe DELETE that allowed factor removal without OTP. Ensure it’s gone or returns 405 in favor of /api/mfa/delete.

#!/bin/bash
# Verify no DELETE handler remains for factors route
rg -nP 'export\s+async\s+function\s+DELETE' app/api/mfa/factors/route.ts
components/ConvexClientProvider.tsx (1)

71-76: Failure counter reset on successful token retrieval looks good.

The implementation correctly resets the failure tracking state when tokens are successfully obtained, addressing the previous concern about persistent failure counts.

Also applies to: 79-84

🧹 Nitpick comments (8)
app/api/mfa/factors/route.ts (3)

22-35: Limit response to non-PII fields (avoid leaking factor.user).

Exposing totp.user is redundant for the authenticated user and may leak provider-side identifiers. Recommend omitting it.

Apply this diff:

-    const transformedFactors = factors.data.map((factor) => ({
+    const transformedFactors = factors.data.map((factor) => ({
       id: factor.id,
       type: factor.type,
       issuer: factor.totp?.issuer,
-      user: factor.totp?.user,
       createdAt: factor.createdAt,
       updatedAt: factor.updatedAt,
     }));

37-39: Disable caching of sensitive account data.

Add Cache-Control: no-store to prevent intermediaries from caching MFA metadata.

-    return NextResponse.json({
-      factors: transformedFactors,
-    });
+    return NextResponse.json(
+      { factors: transformedFactors },
+      { headers: { "Cache-Control": "no-store" } },
+    );

6-20: Optional: Use shared JSON/response helper for consistency.

Other routes use the shared helpers from lib/api/response.ts. Consider aligning here for uniform behavior and easier changes later.

app/api/mfa/verify/route.ts (3)

28-30: Remove redundant userId null check.

getUserID either throws or returns a string; this guard is unreachable.

-    if (!userId) {
-      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-    }

60-74: Make provider error checks case-insensitive.

Avoids brittle matching against message casing.

-    if (error instanceof Error) {
-      if (error.message.includes("already verified")) {
+    if (error instanceof Error) {
+      const msg = error.message.toLowerCase();
+      if (msg.includes("already verified")) {
         return NextResponse.json(
           { error: "Challenge has already been verified" },
           { status: 400 },
         );
       }
-      if (error.message.includes("expired")) {
+      if (msg.includes("expired")) {
         return NextResponse.json(
           { error: "Challenge has expired" },
           { status: 400 },
         );
       }
     }

6-9: Nit: avoid unnecessary type assertion on destructure.

body is already typed; no need for as VerifyMfaRequest during destructuring.

-    const { challengeId, code } = body as VerifyMfaRequest;
+    const { challengeId, code } = body;

Also applies to: 32-39

components/ConvexClientProvider.tsx (2)

39-53: Consider extracting error messages to constants for maintainability.

The hardcoded error strings in shouldForceLogout could be centralized as constants to improve maintainability and ensure consistency across the codebase.

+const FORCE_LOGOUT_ERROR_PATTERNS = [
+  'invalid_grant',
+  'session has already ended',
+  'failed to refresh session'
+] as const;
+
 const shouldForceLogout = (value: unknown): boolean => {
   const message =
     typeof value === "string"
       ? value
       : value && typeof value === "object" && "message" in value
         ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
           (value as any).message
         : undefined;
   if (!message) return false;
   const normalized = String(message).toLowerCase();
-  if (normalized.includes("invalid_grant")) return true;
-  if (normalized.includes("session has already ended")) return true;
-  if (normalized.includes("failed to refresh session")) return true;
-  return false;
+  return FORCE_LOGOUT_ERROR_PATTERNS.some(pattern => 
+    normalized.includes(pattern)
+  );
 };

86-90: Consider using a type guard for better type safety.

The digest check could be improved with a proper type guard for better type safety.

+const hasErrorDigest = (error: unknown): error is { digest: unknown } => {
+  return !!(
+    error &&
+    typeof error === "object" &&
+    "digest" in error
+  );
+};
+
 // In fetchAccessToken:
-const hasDigest = !!(
-  error &&
-  typeof error === "object" &&
-  "digest" in (error as Record<string, unknown>)
-);
+const hasDigest = hasErrorDigest(error);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8858109 and 9588882.

📒 Files selected for processing (9)
  • app/api/logout-all/route.ts (1 hunks)
  • app/api/mfa/delete/route.ts (1 hunks)
  • app/api/mfa/enroll/route.ts (1 hunks)
  • app/api/mfa/factors/route.ts (1 hunks)
  • app/api/mfa/verify/route.ts (1 hunks)
  • app/components/DeleteMfaFactorDialog.tsx (1 hunks)
  • app/components/SecurityTab.tsx (1 hunks)
  • components/ConvexClientProvider.tsx (3 hunks)
  • lib/api/response.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • lib/api/response.ts
  • app/components/SecurityTab.tsx
  • app/api/logout-all/route.ts
  • app/api/mfa/enroll/route.ts
  • app/api/mfa/delete/route.ts
  • app/components/DeleteMfaFactorDialog.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/convex_rules.mdc)

**/*.{ts,tsx}: Use Id helper type from ./_generated/dataModel to type document IDs (e.g., Id<'users'>) instead of string
When defining Record types, specify key and value types matching validators (e.g., Record<Id<'users'>, string>)
Be strict with types for document IDs; prefer Id<'table'> over string in function args and variables
Use as const for string literals in discriminated unions
Declare arrays with explicit generic type: const arr: Array = [...]
Declare records with explicit generic types: const record: Record<KeyType, ValueType> = {...}

Files:

  • app/api/mfa/verify/route.ts
  • app/api/mfa/factors/route.ts
  • components/ConvexClientProvider.tsx
🧬 Code graph analysis (2)
app/api/mfa/verify/route.ts (4)
app/api/mfa/delete/route.ts (1)
  • POST (11-97)
app/api/mfa/enroll/route.ts (1)
  • POST (6-59)
lib/auth/get-user-id.ts (1)
  • getUserID (13-31)
lib/api/response.ts (1)
  • isUnauthorizedError (21-29)
app/api/mfa/factors/route.ts (2)
lib/auth/get-user-id.ts (1)
  • getUserID (13-31)
lib/api/response.ts (1)
  • isUnauthorizedError (21-29)
🔇 Additional comments (2)
app/api/mfa/verify/route.ts (1)

53-56: LGTM: clear, minimal success payload.

Returning { valid, challenge } aligns with the client dialog flow.

components/ConvexClientProvider.tsx (1)

100-106: Time-window logic validated — no change needed.
Successful token fetch resets failureCountRef/lastFailureAtRef (components/ConvexClientProvider.tsx:73–74, 81–82); two failures within 10s trigger logout (components/ConvexClientProvider.tsx:102–107).

Comment on lines +32 to +39
let body: VerifyMfaRequest;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { challengeId, code } = body as VerifyMfaRequest;

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Validate and normalize OTP code (length/digits) before verification.

Prevents malformed input and reduces noisy provider calls; aligns with delete flow.

-    let body: VerifyMfaRequest;
+    let body: VerifyMfaRequest;
     try {
       body = await req.json();
     } catch {
       return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
     }
-    const { challengeId, code } = body as VerifyMfaRequest;
+    const { challengeId, code } = body;

-    if (!challengeId || !code) {
+    const normalizedCode = (code ?? "").toString().trim().replace(/\s+/g, "");
+    if (!challengeId || !normalizedCode) {
       return NextResponse.json(
         { error: "Challenge ID and verification code are required" },
         { status: 400 },
       );
     }
+    if (!/^\d{6}$/.test(normalizedCode)) {
+      return NextResponse.json({ error: "Invalid code" }, { status: 400 });
+    }

     // Verify challenge with WorkOS
     const verification = await workos.mfa.verifyChallenge({
       authenticationChallengeId: challengeId,
-      code: code,
+      code: normalizedCode,
     });

Also applies to: 40-45, 47-51

🤖 Prompt for AI Agents
In app/api/mfa/verify/route.ts around lines 32-39 (and similarly at 40-45 and
47-51), the request body is parsed but the OTP "code" is not validated or
normalized; add validation to ensure the code is present, consists only of ASCII
digits, and has the expected length (e.g. 6 digits) before calling the provider.
Normalize the code by trimming whitespace and removing internal spaces, then
check /^[0-9]+$/ and length === expected; if validation fails, return
NextResponse.json with a 400 status and a clear error message to avoid malformed
inputs and unnecessary provider calls.

Comment on lines +47 to +56
// Verify challenge with WorkOS
const verification = await workos.mfa.verifyChallenge({
authenticationChallengeId: challengeId,
code: code,
});

return NextResponse.json({
valid: verification.valid,
challenge: verification.challenge,
});
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Consider verifying challenge ownership (defense-in-depth).

If the WorkOS verification result exposes factor/user identifiers, assert they belong to userId. Otherwise, fetch ownership info before accepting verification. Please confirm SDK support.


🌐 Web query:

For WorkOS MFA:
- Does verifyChallenge response include the challenge's authenticationFactorId or userId?
- Is there an endpoint to fetch a challenge by ID to validate ownership?
- Recommended pattern for verifying that a challenge belongs to the authenticated user?

💡 Result:

  • Does verifyChallenge include the challenge's authenticationFactorId or userId?

    • The verifyChallenge response includes the challenge object with authentication_factor_id (authentication_factor_id shown in the response) but it does not include a userId. [1]
  • Is there an endpoint to fetch a challenge by ID to validate ownership?

    • WorkOS docs do not document a public "get challenge by ID" endpoint. challenge objects are returned by challengeFactor() and verifyChallenge() calls, but there is no documented standalone GET /challenge/:id. [1][2]
  • Recommended pattern to verify a challenge belongs to the authenticated user

    • Persist the authentication_factor_id (factor ID) in your own user record when enrolling a factor.
    • When you create or verify a challenge, compare the returned challenge.authentication_factor_id to the factor ID(s) you have stored for the currently authenticated user; only accept the verification if they match.
    • Alternatively, use the WorkOS authenticate / pending_authentication_token flow: the MFA challenge error/flow returns a pending_authentication_token and the user object (and their factors); use that token + a challenge that corresponds to a factor you’ve associated with that user to complete authentication. This ties the challenge to the concrete user in your app. [1][2]

Sources:

  • WorkOS MFA docs (challenge & verify responses). [1]
  • WorkOS user-management authenticate / MFA pending_authentication_token flow. [2]

Verify challenge ownership before accepting verification

  • verifyChallenge returns challenge.authentication_factor_id but not a userId and there is no public GET /challenge/:id endpoint.
  • In app/api/mfa/verify/route.ts (lines 47–56) assert verification.challenge.authentication_factor_id matches an authentication_factor_id stored for the authenticated userId; reject the verification on mismatch.
  • If you don’t persist factor IDs at enrollment, persist authentication_factor_id when enrolling factors and use that for comparison, or adopt the WorkOS pending_authentication_token flow to tie the challenge to the concrete user.

Comment on lines +95 to +96
// Redirect immediately if the session has ended / invalid_grant
window.location.href = "/logout";
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consider using Next.js router for navigation.

Using window.location.href for navigation causes a full page reload. Consider using Next.js router for smoother client-side navigation.

Add the router import at the top of the file:

import { useRouter } from 'next/navigation';

Then in the useAuthFromAuthKit function:

 function useAuthFromAuthKit() {
   const { user, loading } = useAuth();
   const { accessToken, getAccessToken, refresh } = useAccessToken();
+  const router = useRouter();
   const failureCountRef = useRef(0);
   const lastFailureAtRef = useRef<number | null>(null);

And replace the window.location redirects:

-  window.location.href = "/logout";
+  router.push("/logout");

Also applies to: 108-109

🤖 Prompt for AI Agents
In components/ConvexClientProvider.tsx around lines 95-96 and 108-109, replace
direct window.location.href assignments with Next.js client-side navigation:
import useRouter from 'next/navigation' at the top, get the router instance
inside the component/hook (e.g., const router = useRouter()), and call
router.push('/logout') (or router.replace('/logout') if you want to avoid
history entries) where window.location.href is currently used; ensure these
redirects occur inside client-side effects/callbacks so router is available.

@rossmanko rossmanko merged commit 8543e40 into main Sep 14, 2025
3 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Sep 19, 2025
This was referenced Sep 27, 2025
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.

2 participants