Skip to content

Comments

feat: improve selfhosted login#14502

Merged
darkskygit merged 3 commits intocanaryfrom
darksky/improve-login-error-handle
Feb 23, 2026
Merged

feat: improve selfhosted login#14502
darkskygit merged 3 commits intocanaryfrom
darksky/improve-login-error-handle

Conversation

@darkskygit
Copy link
Member

@darkskygit darkskygit commented Feb 23, 2026

fix #13397
fix #14011

PR Dependency Tree

This tree was auto-generated by Charcoal

Summary by CodeRabbit

  • New Features

    • Centralized CORS policy with dynamic origin validation applied to server and realtime connections
    • Improved sign-in flows with contextual, localized error hints and toast notifications
    • Centralized network-error normalization and conditional OAuth provider fetching
  • Bug Fixes

    • Better feedback for self-hosted connection failures and clearer authentication error handling
    • More robust handling of network-related failures with user-friendly messages

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 23, 2026

📝 Walkthrough

Walkthrough

Adds a centralized CORS module and applies dynamic origin validation to HTTP and WebSocket endpoints; also enhances frontend sign-in and server-config network error handling and user-facing error messages.

Changes

Cohort / File(s) Summary
CORS Core
packages/backend/server/src/base/cors.ts, packages/backend/server/src/base/index.ts
New CORS module exporting allowed methods/headers/exposed headers, origin-building (buildCorsAllowedOrigins), origin validation (isCorsOriginAllowed), and a callback driver (corsOriginCallback); re-exported from base index.
Server CORS Integration
packages/backend/server/src/server.ts
Replaces permissive CORS with app.enableCors using corsOriginCallback and a computed allowedOrigins set; configures credentials, headers, exposed headers, methods, and maxAge.
WebSocket CORS Integration
packages/backend/server/src/base/websocket/adapter.ts
Socket.IO origin setting changed from true to a dynamic origin function delegating to corsOriginCallback; logs blocked origins and uses shared CORS constants.
Sign-in UX / Error Handling
packages/frontend/core/src/components/sign-in/add-selfhosted.tsx, packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx
Adds UserFriendlyError wrapping, password/field error hints, toast notifications, and improved error state lifecycle (init, reset, and translation-aware updates).
Server Config Networking
packages/frontend/core/src/modules/cloud/stores/server-config.ts
Introduces NETWORK_ERROR_PATTERNS and mapServerConfigError to normalize network failures into UserFriendlyError; conditionally fetches OAuth providers based on ServerFeature flags and merges results.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client as Client (browser / mobile)
  participant Server as Backend HTTP Server
  participant Socket as Socket.IO adapter
  participant CORS as corsOriginCallback
  participant URLH as buildCorsAllowedOrigins(URLHelper)
  participant Logger as AFFiNELogger

  Client->>Server: HTTP request / WebSocket handshake (Origin header)
  Server->>URLH: build allowedOrigins set
  Server->>CORS: corsOriginCallback(origin, allowedOrigins, onBlocked, cb)
  CORS->>CORS: isCorsOriginAllowed(origin, allowedOrigins)
  alt origin blocked
    CORS->>Logger: onBlocked(origin) (log blocked origin)
    CORS-->>Server: cb(null, false)
    Server-->>Client: Respond with CORS denied / handshake rejected
  else origin allowed
    CORS-->>Server: cb(null, true)
    Server-->>Client: Proceed with request / establish connection
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I checked each origin, hopping through the gate,
I logged the blockers, kept the good ones straight,
Password hints now whisper when login goes awry,
OAuths fetched only when servers reply,
A rabbit's patch: secure, polite, and light! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ 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%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: improve selfhosted login' is partially related to the changeset. While the PR includes improvements to self-hosted login error handling and CORS configuration, the title does not fully capture the scope - which includes backend CORS hardening (origins, headers, methods) and network error handling across multiple components.
Linked Issues check ✅ Passed The PR addresses both linked issues: #13397 improves self-hosted login error handling with user-friendly error messages for credential failures, and #14011 is addressed through centralized CORS configuration supporting proper header handling and origin validation.
Out of Scope Changes check ✅ Passed All changes are focused on self-hosted login improvements and CORS handling. Backend changes introduce centralized CORS logic and validation; frontend changes add user-friendly error handling for sign-in flows and server configuration fetching. No unrelated changes detected.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch darksky/improve-login-error-handle

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

🧹 Nitpick comments (4)
packages/backend/server/src/base/cors.ts (1)

66-83: Permissive behavior for missing origin — document the security rationale.

When origin is undefined or null, isCorsOriginAllowed returns true (lines 70-72). This is standard practice (server-to-server calls, same-origin requests, and non-browser clients don't send Origin), but it means CORS alone doesn't protect against non-browser access. A brief inline comment explaining why missing origins are allowed would help future maintainers.

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

In `@packages/backend/server/src/base/cors.ts` around lines 66 - 83, Add a brief
inline comment inside isCorsOriginAllowed explaining why a missing origin
(origin === undefined || null) returns true: note that browsers omit Origin for
same-origin requests and non-browser or server-to-server clients don't send it,
so CORS cannot and should not block those requests; clarify that this is
intentional and that other server-side authentication/authorization must be
relied on for security. Place the comment near the existing origin check in
isCorsOriginAllowed and mention the related dev/testing branch (env.dev /
env.testing and isDevLoopbackOrigin) to make the security rationale clear to
future maintainers.
packages/backend/server/src/server.ts (1)

43-59: Minor inconsistency: HTTP CORS includes exposedHeaders and maxAge but WebSocket CORS does not.

The HTTP CORS config (here) specifies exposedHeaders: CORS_EXPOSED_HEADERS and maxAge: 86400, while the Socket.IO adapter (adapter.ts lines 35-53) omits both. While maxAge is less relevant for WebSocket upgrades (no preflight), exposedHeaders may matter if your WebSocket transport falls back to HTTP long-polling. Consider aligning them for consistency, or add a comment explaining why they differ.

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

In `@packages/backend/server/src/server.ts` around lines 43 - 59, The HTTP CORS
setup (app.enableCors) includes exposedHeaders (CORS_EXPOSED_HEADERS) and maxAge
(86400) but the Socket.IO adapter CORS config in adapter.ts omits them; update
the Socket.IO CORS options used in the adapter (or the function that constructs
them, e.g., the Socket.IO server/adapter initialization) to include
exposedHeaders: CORS_EXPOSED_HEADERS and maxAge: 86400 for consistency (or, if
you intentionally omit them, add a brief comment in adapter.ts explaining why
exposedHeaders and maxAge are not needed for WebSocket/long-poll fallback).
packages/frontend/core/src/components/sign-in/add-selfhosted.tsx (1)

82-104: Duplicate error → message mapping with an inconsistent fallback.

The error-to-message logic is implemented twice — once for setErrorHint and again for notify.error — with subtly different fallbacks: the setErrorHint else branch shows a generic string, while the notify.error else branch uses a dynamic error.${userFriendlyError.name} key. Adding a new handled error type in the future requires updating both blocks in sync, and the two fallbacks already diverge.

Extracting a single helper keeps the mapping in one place:

♻️ Proposed refactor
+  // Derive a single user-visible message from the error
+  function toErrorMessage(err: UserFriendlyError, t: ReturnType<typeof useI18n>): string {
+    if (err.is('TOO_MANY_REQUEST')) return t['error.TOO_MANY_REQUEST']();
+    if (err.is('NETWORK_ERROR') || err.is('REQUEST_ABORTED')) return t['error.NETWORK_ERROR']();
+    return t[`error.${err.name}`](err.data);
+  }

   } catch (err) {
     console.error(err);
     const userFriendlyError = UserFriendlyError.fromAny(err);
     setError(true);
-    if (userFriendlyError.is('TOO_MANY_REQUEST')) {
-      setErrorHint(t['error.TOO_MANY_REQUEST']());
-    } else if (
-      userFriendlyError.is('NETWORK_ERROR') ||
-      userFriendlyError.is('REQUEST_ABORTED')
-    ) {
-      setErrorHint(t['error.NETWORK_ERROR']());
-    } else {
-      setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']());
-    }
-
-    notify.error({
-      title: t['com.affine.auth.toast.title.failed'](),
-      message:
-        userFriendlyError.is('REQUEST_ABORTED') ||
-        userFriendlyError.is('NETWORK_ERROR')
-          ? t['error.NETWORK_ERROR']()
-          : userFriendlyError.is('TOO_MANY_REQUEST')
-            ? t['error.TOO_MANY_REQUEST']()
-            : t[`error.${userFriendlyError.name}`](userFriendlyError.data),
-    });
+    const message = toErrorMessage(userFriendlyError, t);
+    setErrorHint(message);
+    notify.error({ title: t['com.affine.auth.toast.title.failed'](), message });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/core/src/components/sign-in/add-selfhosted.tsx` around
lines 82 - 104, The error mapping is duplicated and the fallbacks diverge;
create a single helper (e.g., getErrorMessageKey or mapUserFriendlyError) that
accepts a UserFriendlyError and returns the canonical translation key (and
optionally the data payload) for that error (handle 'TOO_MANY_REQUEST' =>
'error.TOO_MANY_REQUEST', 'NETWORK_ERROR'/'REQUEST_ABORTED' =>
'error.NETWORK_ERROR', otherwise => `error.${userFriendlyError.name}`). Replace
the two branches in setErrorHint and notify.error to call that helper: use
t[key]() for the hint and t[key](data) for the toast message, and keep
setError(true) as-is; update references to UserFriendlyError, setErrorHint, and
notify.error accordingly.
packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx (1)

50-50: passwordErrorHint is a derived value, not state — simplify to a const

Every code path assigns it the exact same expression: t['com.affine.auth.password.error'](). The useEffect + useState pair exists only to keep that derived value in sync with t, which is exactly what a plain variable already does. This also eliminates the confusing setPasswordErrorHint call in the onChange handler (Line 156), which is visually a no-op because passwordError is simultaneously cleared.

♻️ Proposed simplification
- const [passwordErrorHint, setPasswordErrorHint] = useState('');
+ const passwordErrorHint = t['com.affine.auth.password.error']();
- useEffect(() => {
-   setPasswordErrorHint(t['com.affine.auth.password.error']());
- }, [t]);
-
  if (
    error.is('WRONG_SIGN_IN_CREDENTIALS') ||
    error.is('PASSWORD_REQUIRED')
  ) {
    setPasswordError(true);
-   setPasswordErrorHint(t['com.affine.auth.password.error']());
  } else {
  if (passwordError) {
    setPasswordError(false);
-   setPasswordErrorHint(t['com.affine.auth.password.error']());
  }

Also applies to: 79-81

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

In `@packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx` at
line 50, Replace the derived state passwordErrorHint with a plain constant:
remove the useState hook and the related useEffect that sets
setPasswordErrorHint and instead declare const passwordErrorHint =
t['com.affine.auth.password.error'](); also remove any calls to
setPasswordErrorHint (e.g., in the onChange handler) and keep the existing logic
that clears passwordError; update references to passwordErrorHint elsewhere in
this component (including the input onChange and any rendering code) to use the
new constant.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/frontend/core/src/components/sign-in/add-selfhosted.tsx`:
- Around line 43-49: The current code unconditionally overwrites a possibly
specific error hint on every locale change and does a two-phase init; change the
useState('') to a lazy initializer that calls
t['com.affine.auth.sign.add-selfhosted.error']() so errorHint starts with the
correct localized value, remove or stop using the useEffect([t]) that blindly
calls setErrorHint on every t change, and when resetting the hint in
onBaseURLChange continue to reset it by calling
setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']()) (not a blank
string) so specific errors like error.TOO_MANY_REQUEST are preserved until
explicitly changed.

---

Nitpick comments:
In `@packages/backend/server/src/base/cors.ts`:
- Around line 66-83: Add a brief inline comment inside isCorsOriginAllowed
explaining why a missing origin (origin === undefined || null) returns true:
note that browsers omit Origin for same-origin requests and non-browser or
server-to-server clients don't send it, so CORS cannot and should not block
those requests; clarify that this is intentional and that other server-side
authentication/authorization must be relied on for security. Place the comment
near the existing origin check in isCorsOriginAllowed and mention the related
dev/testing branch (env.dev / env.testing and isDevLoopbackOrigin) to make the
security rationale clear to future maintainers.

In `@packages/backend/server/src/server.ts`:
- Around line 43-59: The HTTP CORS setup (app.enableCors) includes
exposedHeaders (CORS_EXPOSED_HEADERS) and maxAge (86400) but the Socket.IO
adapter CORS config in adapter.ts omits them; update the Socket.IO CORS options
used in the adapter (or the function that constructs them, e.g., the Socket.IO
server/adapter initialization) to include exposedHeaders: CORS_EXPOSED_HEADERS
and maxAge: 86400 for consistency (or, if you intentionally omit them, add a
brief comment in adapter.ts explaining why exposedHeaders and maxAge are not
needed for WebSocket/long-poll fallback).

In `@packages/frontend/core/src/components/sign-in/add-selfhosted.tsx`:
- Around line 82-104: The error mapping is duplicated and the fallbacks diverge;
create a single helper (e.g., getErrorMessageKey or mapUserFriendlyError) that
accepts a UserFriendlyError and returns the canonical translation key (and
optionally the data payload) for that error (handle 'TOO_MANY_REQUEST' =>
'error.TOO_MANY_REQUEST', 'NETWORK_ERROR'/'REQUEST_ABORTED' =>
'error.NETWORK_ERROR', otherwise => `error.${userFriendlyError.name}`). Replace
the two branches in setErrorHint and notify.error to call that helper: use
t[key]() for the hint and t[key](data) for the toast message, and keep
setError(true) as-is; update references to UserFriendlyError, setErrorHint, and
notify.error accordingly.

In `@packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx`:
- Line 50: Replace the derived state passwordErrorHint with a plain constant:
remove the useState hook and the related useEffect that sets
setPasswordErrorHint and instead declare const passwordErrorHint =
t['com.affine.auth.password.error'](); also remove any calls to
setPasswordErrorHint (e.g., in the onChange handler) and keep the existing logic
that clears passwordError; update references to passwordErrorHint elsewhere in
this component (including the input onChange and any rendering code) to use the
new constant.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between ef6717e and a671141.

📒 Files selected for processing (7)
  • packages/backend/server/src/base/cors.ts
  • packages/backend/server/src/base/index.ts
  • packages/backend/server/src/base/websocket/adapter.ts
  • packages/backend/server/src/server.ts
  • packages/frontend/core/src/components/sign-in/add-selfhosted.tsx
  • packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx
  • packages/frontend/core/src/modules/cloud/stores/server-config.ts

@codecov
Copy link

codecov bot commented Feb 23, 2026

Codecov Report

❌ Patch coverage is 78.29457% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.06%. Comparing base (ef6717e) to head (e0d5b0a).
⚠️ Report is 3 commits behind head on canary.

Files with missing lines Patch % Lines
packages/backend/server/src/base/cors.ts 73.73% 26 Missing ⚠️
...kages/backend/server/src/base/websocket/adapter.ts 93.10% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           canary   #14502      +/-   ##
==========================================
- Coverage   57.46%   57.06%   -0.40%     
==========================================
  Files        2860     2861       +1     
  Lines      154583   154708     +125     
  Branches    23368    23246     -122     
==========================================
- Hits        88829    88291     -538     
- Misses      62845    63481     +636     
- Partials     2909     2936      +27     
Flag Coverage Δ
server-test 75.12% <78.29%> (-0.83%) ⬇️
unittest 34.02% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
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.

🧹 Nitpick comments (1)
packages/frontend/core/src/components/sign-in/add-selfhosted.tsx (1)

77-105: Duplicated error-type conditional logic between errorHint and notify.error.

The same three-way branch (TOO_MANY_REQUEST / NETWORK_ERROR|REQUEST_ABORTED / fallback) is evaluated twice — once for setErrorHint (lines 81–90) and again inline in the notify.error call (lines 94–100) — with slightly different ordering. Extracting a shared helper eliminates the duplication and, as a bonus, makes it straightforward to apply the safe-fallback fix from above in one place.

♻️ Proposed refactor
+      const getErrorMessage = () => {
+        if (userFriendlyError.is('TOO_MANY_REQUEST')) return t['error.TOO_MANY_REQUEST']();
+        if (userFriendlyError.is('NETWORK_ERROR') || userFriendlyError.is('REQUEST_ABORTED'))
+          return t['error.NETWORK_ERROR']();
+        return (
+          (t[`error.${userFriendlyError.name}`] as ((...args: unknown[]) => string) | undefined)?.(
+            userFriendlyError.data
+          ) ?? t['com.affine.auth.sign.add-selfhosted.error']()
+        );
+      };
+      const errorMessage = getErrorMessage();
+
       setError(true);
-      if (userFriendlyError.is('TOO_MANY_REQUEST')) {
-        setErrorHint(t['error.TOO_MANY_REQUEST']());
-      } else if (
-        userFriendlyError.is('NETWORK_ERROR') ||
-        userFriendlyError.is('REQUEST_ABORTED')
-      ) {
-        setErrorHint(t['error.NETWORK_ERROR']());
-      } else {
-        setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']());
-      }
+      setErrorHint(errorMessage);

       notify.error({
         title: t['com.affine.auth.toast.title.failed'](),
-        message:
-          userFriendlyError.is('REQUEST_ABORTED') ||
-          userFriendlyError.is('NETWORK_ERROR')
-            ? t['error.NETWORK_ERROR']()
-            : userFriendlyError.is('TOO_MANY_REQUEST')
-              ? t['error.TOO_MANY_REQUEST']()
-              : t[`error.${userFriendlyError.name}`](userFriendlyError.data),
+        message: errorMessage,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/core/src/components/sign-in/add-selfhosted.tsx` around
lines 77 - 105, The code duplicates the same error-type branching to
setErrorHint and to build notify.error message in the catch block of the
add-selfhosted component; create a single helper (e.g., mapUserFriendlyError or
getErrorResponse) that accepts the UserFriendlyError instance and returns both
the hint string and the toast message string (or an object with {hint,
toastMessage}) and use it in the catch handler to call setErrorHint(...) and
notify.error({...}) so the branching logic for
UserFriendlyError.is('TOO_MANY_REQUEST'), .is('NETWORK_ERROR' /
'REQUEST_ABORTED'), and the fallback is centralized; update references to
UserFriendlyError, setErrorHint, and notify.error in the catch block to use this
helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/frontend/core/src/components/sign-in/add-selfhosted.tsx`:
- Around line 77-105: The code duplicates the same error-type branching to
setErrorHint and to build notify.error message in the catch block of the
add-selfhosted component; create a single helper (e.g., mapUserFriendlyError or
getErrorResponse) that accepts the UserFriendlyError instance and returns both
the hint string and the toast message string (or an object with {hint,
toastMessage}) and use it in the catch handler to call setErrorHint(...) and
notify.error({...}) so the branching logic for
UserFriendlyError.is('TOO_MANY_REQUEST'), .is('NETWORK_ERROR' /
'REQUEST_ABORTED'), and the fallback is centralized; update references to
UserFriendlyError, setErrorHint, and notify.error in the catch block to use this
helper.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a671141 and e0d5b0a.

📒 Files selected for processing (1)
  • packages/frontend/core/src/components/sign-in/add-selfhosted.tsx

@darkskygit darkskygit merged commit 91c5869 into canary Feb 23, 2026
59 checks passed
@darkskygit darkskygit deleted the darksky/improve-login-error-handle branch February 23, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Bug]: Reverse Proxy Headers - Can't connect to iOS or Android apps [Bug]: Invalid Password on Self-Hosted Android App

1 participant