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
@@ -0,0 +1,95 @@
import { notify } from '@affine/component';
import {
AuthService,
type DeviceAuthSession,
} from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
export const DevicesGroup = () => {
const t = useI18n();
const auth = useService(AuthService);
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
const reload = useCallback(() => {
void auth
.listDeviceSessions()
.then(setSessions)
.catch(error => {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
});
}, [auth, t]);
useEffect(reload, [reload]);
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) reload();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-failed'](),
message: String(error),
});
}
},
[auth, reload, t]
);
return (
<SettingGroup title={t['com.affine.settings.devices.title']()}>
{sessions.map(session => (
<RowLayout
key={session.id}
label={
<div>
<div>{`${session.deviceName ?? session.platform}${session.current ? ` (${t['com.affine.settings.devices.current']()})` : ''}`}</div>
<div>
{t['com.affine.settings.devices.last-used']({
time: new Date(session.lastSeenAt).toLocaleString(),
})}
</div>
</div>
}
onClick={() => void revoke(session)}
>
{t['com.affine.settings.devices.sign-out']()}
</RowLayout>
))}
{sessions.length > 1 ? (
<RowLayout
label={t['com.affine.settings.devices.sign-out-all']()}
onClick={() => {
if (
window.confirm(t['com.affine.settings.devices.confirm-all']())
) {
void auth.revokeAllDeviceSessions().catch(error => {
notify.error({
title: t['com.affine.settings.devices.sign-out-all-failed'](),
message: String(error),
});
});
}
}}
/>
) : null}
</SettingGroup>
);
};
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
import { AboutGroup } from './about';
import { AppearanceGroup } from './appearance';
import { DevicesGroup } from './devices';
import { ExperimentalFeatureSetting } from './experimental';
import { OthersGroup } from './others';
import * as styles from './style.css';
@@ -26,6 +27,7 @@ const MobileSetting = () => {
<UserProfile />
<UserSubscription />
<UserUsage />
<DevicesGroup />
<AppearanceGroup />
<AboutGroup />
<ExperimentalFeatureSetting />