feat(core): improve login flow (#15219)

#### PR Dependency Tree


* **PR #15219** 👈

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**
* Added secure, automatic auth session token refresh and request replay
for expired-token responses across Android, iOS, and Electron.
* Updated sign-in flows to manage sessions without returning tokens to
the app layer.
* Added “Devices” management UI with sign out per device and sign out
all.
  * Enabled support for both Hashcash and Turnstile captcha providers.
* **Bug Fixes**
* Improved refresh de-duplication, inflight cancellation/clear behavior,
and recovery from corrupted/invalid sessions.
* **Tests**
* Expanded auth-session, refresh/revoke, and replay coverage (Electron
unit tests, Android instrumentation tests, iOS auth date parser tests).
* **Chores**
* Removed CAPTCHA site key from build-time configuration and adjusted CI
test execution.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-12 18:11:02 +08:00
committed by GitHub
parent 02b25e05d8
commit abf37d3dfa
57 changed files with 2919 additions and 789 deletions
@@ -18,7 +18,11 @@ import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { AuthService, ServerService } from '../../../../modules/cloud';
import {
AuthService,
type DeviceAuthSession,
ServerService,
} from '../../../../modules/cloud';
import type { SettingState } from '../types';
import { AIUsagePanel } from './ai-usage-panel';
import { DeleteAccount } from './delete-account';
@@ -170,6 +174,114 @@ const StoragePanel = ({
);
};
const DevicesPanel = () => {
const t = useI18n();
const auth = useService(AuthService);
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
setLoading(true);
try {
setSessions(await auth.listDeviceSessions());
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
} finally {
setLoading(false);
}
}, [auth, t]);
useEffect(() => {
reload().catch(error => {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
});
}, [reload, t]);
const revoke = useCallback(
async (session: DeviceAuthSession) => {
if (
!window.confirm(
t['com.affine.settings.devices.confirm']({
device: session.deviceName ?? session.platform,
})
)
) {
return;
}
try {
await auth.revokeDeviceSession(session.id, session.current);
if (!session.current) await reload();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-failed'](),
message: String(error),
});
}
},
[auth, reload, t]
);
const revokeAll = useCallback(async () => {
if (!window.confirm(t['com.affine.settings.devices.confirm-all']())) return;
try {
await auth.revokeAllDeviceSessions();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-all-failed'](),
message: String(error),
});
}
}, [auth, t]);
return (
<SettingRow
name={t['com.affine.settings.devices.title']()}
desc={t['com.affine.settings.devices.description']()}
spreadCol={false}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{loading ? (
<span>{t['com.affine.settings.devices.loading']()}</span>
) : null}
{sessions.map(session => (
<div
key={session.id}
style={{ display: 'flex', alignItems: 'center', gap: 12 }}
>
<div style={{ flex: 1 }}>
<div>
{session.deviceName ?? session.platform}
{session.current
? ` (${t['com.affine.settings.devices.current']()})`
: ''}
</div>
<div>
{session.platform}
{session.appVersion ? ` · ${session.appVersion}` : ''}
{` · ${t['com.affine.settings.devices.last-used']({ time: new Date(session.lastSeenAt).toLocaleString() })}`}
</div>
</div>
<Button onClick={() => void revoke(session)}>
{t['com.affine.settings.devices.sign-out']()}
</Button>
</div>
))}
{sessions.length > 1 ? (
<Button onClick={() => void revokeAll()}>
{t['com.affine.settings.devices.sign-out-all']()}
</Button>
) : null}
</div>
</SettingRow>
);
};
export const AccountSetting = ({
onChangeSettingState,
}: {
@@ -244,6 +356,7 @@ export const AccountSetting = ({
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<DevicesPanel />
<StoragePanel onChangeSettingState={onChangeSettingState} />
{serverFeatures?.copilot && (
<AIUsagePanel onChangeSettingState={onChangeSettingState} />