feat: improve selfhosted login (#14502)

fix #13397
fix #14011

#### PR Dependency Tree


* **PR #14502** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-23 21:23:01 +08:00
committed by GitHub
parent 6d805b302c
commit 91c5869053
7 changed files with 294 additions and 41 deletions
@@ -1,4 +1,4 @@
import { Button } from '@affine/component';
import { Button, notify } from '@affine/component';
import {
AuthContainer,
AuthContent,
@@ -8,6 +8,7 @@ import {
} from '@affine/component/auth-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { ServersService } from '@affine/core/modules/cloud';
import { UserFriendlyError } from '@affine/error';
import { Trans, useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import {
@@ -35,12 +36,14 @@ export const AddSelfhostedStep = ({
state: SignInState;
changeState: Dispatch<SetStateAction<SignInState>>;
}) => {
const t = useI18n();
const serversService = useService(ServersService);
const [baseURL, setBaseURL] = useState(state.initialServerBaseUrl ?? '');
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<boolean>(false);
const t = useI18n();
const [errorHint, setErrorHint] = useState(
t['com.affine.auth.sign.add-selfhosted.error']()
);
const urlValid = useMemo(() => {
try {
@@ -51,10 +54,14 @@ export const AddSelfhostedStep = ({
}
}, [baseURL]);
const onBaseURLChange = useCallback((value: string) => {
setBaseURL(value);
setError(false);
}, []);
const onBaseURLChange = useCallback(
(value: string) => {
setBaseURL(value);
setError(false);
setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']());
},
[t]
);
const onConnect = useAsyncCallback(async () => {
setIsConnecting(true);
@@ -69,11 +76,33 @@ export const AddSelfhostedStep = ({
}));
} 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']());
}
setIsConnecting(false);
}, [baseURL, changeState, serversService]);
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),
});
} finally {
setIsConnecting(false);
}
}, [baseURL, changeState, serversService, t]);
useEffect(() => {
if (state.initialServerBaseUrl) {
@@ -101,7 +130,7 @@ export const AddSelfhostedStep = ({
placeholder="https://your-server.com"
error={!!error}
disabled={isConnecting}
errorHint={t['com.affine.auth.sign.add-selfhosted.error']()}
errorHint={errorHint}
onEnter={onConnect}
/>
<Button
@@ -15,6 +15,7 @@ import {
} from '@affine/core/modules/cloud';
import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session';
import { Unreachable } from '@affine/env/constant';
import { UserFriendlyError } from '@affine/error';
import { ServerDeploymentType } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
@@ -46,6 +47,7 @@ export const SignInWithPasswordStep = ({
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState(false);
const [passwordErrorHint, setPasswordErrorHint] = useState('');
const captchaService = useService(CaptchaService);
const serverService = useService(ServerService);
const isSelfhosted = useLiveData(
@@ -74,6 +76,10 @@ export const SignInWithPasswordStep = ({
onAuthenticated?.(loginStatus);
}, [loginStatus, onAuthenticated, t]);
useEffect(() => {
setPasswordErrorHint(t['com.affine.auth.password.error']());
}, [t]);
const onSignIn = useAsyncCallback(async () => {
if (isLoading || (!verifyToken && needCaptcha)) return;
setIsLoading(true);
@@ -88,7 +94,23 @@ export const SignInWithPasswordStep = ({
});
} catch (err) {
console.error(err);
setPasswordError(true);
const error = UserFriendlyError.fromAny(err);
if (
error.is('WRONG_SIGN_IN_CREDENTIALS') ||
error.is('PASSWORD_REQUIRED')
) {
setPasswordError(true);
setPasswordErrorHint(t['com.affine.auth.password.error']());
} else {
setPasswordError(false);
notify.error({
title: t['com.affine.auth.toast.title.failed'](),
message: error.is('REQUEST_ABORTED')
? t['error.NETWORK_ERROR']()
: t[`error.${error.name}`](error.data),
});
}
} finally {
setIsLoading(false);
}
@@ -101,6 +123,7 @@ export const SignInWithPasswordStep = ({
email,
password,
challenge,
t,
]);
const sendMagicLink = useCallback(() => {
@@ -126,11 +149,15 @@ export const SignInWithPasswordStep = ({
label={t['com.affine.auth.password']()}
value={password}
type="password"
onChange={useCallback((value: string) => {
onChange={(value: string) => {
setPassword(value);
}, [])}
if (passwordError) {
setPasswordError(false);
setPasswordErrorHint(t['com.affine.auth.password.error']());
}
}}
error={passwordError}
errorHint={t['com.affine.auth.password.error']()}
errorHint={passwordErrorHint}
onEnter={onSignIn}
/>
{!isSelfhosted && (