fix: 令牌失效未退出的bug

This commit is contained in:
chaoszhu
2026-07-05 16:43:55 +08:00
parent f162acc347
commit ac68a53e78
3 changed files with 82 additions and 88 deletions
+19 -13
View File
@@ -157,6 +157,7 @@ class _AppRoot extends ConsumerStatefulWidget {
class _AppRootState extends ConsumerState<_AppRoot> {
late final LoginController _loginController;
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<ScaffoldMessengerState> _messengerKey =
GlobalKey<ScaffoldMessengerState>();
@@ -210,6 +211,15 @@ class _AppRootState extends ConsumerState<_AppRoot> {
final auth = ref.watch(authProvider);
final appStorage = ref.watch(appStorageProvider);
ref.listen<bool>(authProvider.select((state) => state.signedIn), (
previous,
next,
) {
if (previous == true && !next) {
_navigatorKey.currentState?.popUntil((route) => route.isFirst);
}
});
// Show a SnackBar whenever the 401/403 interceptor stashes a reason, then
// clear it so the same message isn't shown twice on rebuilds.
ref.listen<String?>(signOutReasonProvider, (_, next) {
@@ -249,6 +259,7 @@ class _AppRootState extends ConsumerState<_AppRoot> {
return MaterialApp(
title: 'EasyNode',
navigatorKey: _navigatorKey,
scaffoldMessengerKey: _messengerKey,
themeMode: ref.watch(themeModeProvider),
theme: ThemeData(
@@ -321,23 +332,18 @@ class _BrandedSplashGateState extends State<_BrandedSplashGate>
vsync: this,
duration: const Duration(milliseconds: 850),
);
_logoScale = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
);
_logoScale = CurvedAnimation(parent: _controller, curve: Curves.elasticOut);
_logoFade = CurvedAnimation(
parent: _controller,
curve: const Interval(0, 0.45, curve: Curves.easeOut),
);
_textOffset = Tween<Offset>(
begin: const Offset(0, 0.35),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.18, 0.7, curve: Curves.easeOutBack),
),
);
_textOffset = Tween<Offset>(begin: const Offset(0, 0.35), end: Offset.zero)
.animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.18, 0.7, curve: Curves.easeOutBack),
),
);
unawaited(_controller.forward());
_timer = Timer(const Duration(milliseconds: 1250), () {
if (mounted) setState(() => _showSplash = false);
@@ -54,8 +54,9 @@ class _AccountSecurityPageState extends ConsumerState<AccountSecurityPage> {
Future<void> _loadMfa() async {
try {
final enabled =
await ref.read(settingsRepositoryProvider).getMfa2Status();
final enabled = await ref
.read(settingsRepositoryProvider)
.getMfa2Status();
if (!mounted) return;
setState(() {
_mfaEnabled = enabled;
@@ -101,7 +102,9 @@ class _AccountSecurityPageState extends ConsumerState<AccountSecurityPage> {
setState(() => _saving = true);
try {
await ref.read(settingsRepositoryProvider).updateAccount(
await ref
.read(settingsRepositoryProvider)
.updateAccount(
oldLoginName: oldName,
oldPwd: oldPwd,
newLoginName: newName,
@@ -110,6 +113,8 @@ class _AccountSecurityPageState extends ConsumerState<AccountSecurityPage> {
if (!mounted) return;
_showSnack(l.tr('account.changed'));
await ref.read(authProvider.notifier).signOut();
} on UnauthorizedFailure {
await ref.read(authProvider.notifier).signOut();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
@@ -124,8 +129,7 @@ class _AccountSecurityPageState extends ConsumerState<AccountSecurityPage> {
Future<void> _startEnableMfa() async {
setState(() => _enableLoading = true);
try {
final setup =
await ref.read(settingsRepositoryProvider).getMfa2QrInfo();
final setup = await ref.read(settingsRepositoryProvider).getMfa2QrInfo();
if (!mounted) return;
setState(() => _setup = setup);
} on ApiFailure catch (err) {
@@ -221,8 +225,7 @@ class _AccountSecurityPageState extends ConsumerState<AccountSecurityPage> {
child: Text(l.tr('common.cancel')),
),
FilledButton(
onPressed: () =>
Navigator.of(ctx).pop(controller.text.trim()),
onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
child: Text(confirmText),
),
],
@@ -274,12 +277,10 @@ class _AccountSecurityPageState extends ConsumerState<AccountSecurityPage> {
showNewPwd: _showNewPwd,
showNewPwdConfirm: _showNewPwdConfirm,
saving: _saving,
onToggleOldPwd: () =>
setState(() => _showOldPwd = !_showOldPwd),
onToggleNewPwd: () =>
setState(() => _showNewPwd = !_showNewPwd),
onToggleNewPwdConfirm: () => setState(
() => _showNewPwdConfirm = !_showNewPwdConfirm),
onToggleOldPwd: () => setState(() => _showOldPwd = !_showOldPwd),
onToggleNewPwd: () => setState(() => _showNewPwd = !_showNewPwd),
onToggleNewPwdConfirm: () =>
setState(() => _showNewPwdConfirm = !_showNewPwdConfirm),
onSubmit: _saveAccount,
),
const SizedBox(height: 22),
@@ -390,10 +391,7 @@ class _CredentialsCard extends StatelessWidget {
controller: oldPwdCtrl,
hintText: l.tr('account.oldPwd'),
obscure: !showOldPwd,
suffix: _EyeButton(
visible: showOldPwd,
onTap: onToggleOldPwd,
),
suffix: _EyeButton(visible: showOldPwd, onTap: onToggleOldPwd),
validator: (v) => (v == null || v.isEmpty)
? l.tr('account.pwdRequired')
: null,
@@ -417,10 +415,7 @@ class _CredentialsCard extends StatelessWidget {
controller: newPwdCtrl,
hintText: l.tr('account.newPwd'),
obscure: !showNewPwd,
suffix: _EyeButton(
visible: showNewPwd,
onTap: onToggleNewPwd,
),
suffix: _EyeButton(visible: showNewPwd, onTap: onToggleNewPwd),
validator: (v) => (v == null || v.isEmpty)
? l.tr('account.pwdRequired')
: null,
@@ -543,22 +538,18 @@ class _TextInput extends StatelessWidget {
controller: controller,
obscureText: obscure,
validator: validator,
style: TextStyle(
fontSize: 14,
color: context.colors.text,
),
style: TextStyle(fontSize: 14, color: context.colors.text),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(
fontSize: 13,
color: context.colors.softMuted,
),
hintStyle: TextStyle(fontSize: 13, color: context.colors.softMuted),
isDense: true,
filled: true,
fillColor: context.colors.canvas,
suffixIcon: suffix,
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: context.colors.border),
@@ -799,8 +790,8 @@ class _MfaCardState extends State<_MfaCard> {
onPressed: widget.enableLoading
? null
: () => widget.onSubmitEnable(
_codeCtrl.text.trim(),
),
_codeCtrl.text.trim(),
),
style: FilledButton.styleFrom(
backgroundColor: context.colors.primary,
foregroundColor: context.colors.fontOnPrimary,
@@ -878,14 +869,8 @@ class _MfaStatusChip extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
child: Text(
enabled
? l.tr('account.mfa.statusOn')
: l.tr('account.mfa.statusOff'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: fg,
),
enabled ? l.tr('account.mfa.statusOn') : l.tr('account.mfa.statusOff'),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: fg),
),
);
}
+37 -34
View File
@@ -127,6 +127,9 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
Future<void> _revoke(LoginSession session) async {
final l = AppLocalizations.of(context);
final currentDeviceId = ref.read(authProvider).session?.deviceId ?? '';
final revokingCurrentSession =
session.deviceId.isNotEmpty && session.deviceId == currentDeviceId;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -151,12 +154,21 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
if (confirmed != true || !mounted) return;
setState(() => _revokingId = session.id);
try {
await ref
.read(settingsRepositoryProvider)
.revokeSession(session.id);
await ref.read(settingsRepositoryProvider).revokeSession(session.id);
if (!mounted) return;
if (revokingCurrentSession) {
await ref.read(authProvider.notifier).signOut();
return;
}
_showSnack(l.tr('sessions.revokeDone'));
await ref.read(loginLogProvider.notifier).refresh();
} on UnauthorizedFailure catch (err) {
if (!mounted) return;
if (!revokingCurrentSession) {
_showSnack(err.message);
return;
}
await ref.read(authProvider.notifier).signOut();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
@@ -204,10 +216,7 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
color: context.colors.danger,
),
)
: Icon(
Icons.delete_outline,
color: context.colors.danger,
),
: Icon(Icons.delete_outline, color: context.colors.danger),
),
const SizedBox(width: 6),
],
@@ -215,8 +224,7 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
body: RefreshIndicator(
onRefresh: () => runRefreshWithFeedback(
context,
() =>
ref.read(loginLogProvider.notifier).refresh(throwOnError: true),
() => ref.read(loginLogProvider.notifier).refresh(throwOnError: true),
),
child: logAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -235,8 +243,7 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
Widget _buildBody(LoginLogData data) {
final l = AppLocalizations.of(context);
final currentDeviceId =
ref.watch(authProvider).session?.deviceId ?? '';
final currentDeviceId = ref.watch(authProvider).session?.deviceId ?? '';
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
@@ -247,9 +254,7 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
dirty: _whitelistDirty,
onAdd: _addIp,
onRemove: _removeIp,
onSave: _whitelistDirty && !_whitelistSaving
? _saveWhitelist
: null,
onSave: _whitelistDirty && !_whitelistSaving ? _saveWhitelist : null,
),
const SizedBox(height: 18),
_SectionHeader(
@@ -264,14 +269,13 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
padding: const EdgeInsets.only(bottom: 10),
child: _SessionCard(
session: session,
isCurrent: session.deviceId.isNotEmpty &&
isCurrent:
session.deviceId.isNotEmpty &&
session.deviceId == currentDeviceId,
createLabel: _formatTimestamp(session.createAt),
expireLabel: _formatTimestamp(session.expireAt),
revoking: _revokingId == session.id,
onRevoke: session.revoked
? null
: () => _revoke(session),
onRevoke: session.revoked ? null : () => _revoke(session),
),
),
],
@@ -345,10 +349,7 @@ class _IpWhitelistCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
l.tr('sessions.ipEmpty'),
style: TextStyle(
fontSize: 12,
color: context.colors.softMuted,
),
style: TextStyle(fontSize: 12, color: context.colors.softMuted),
),
)
else
@@ -547,11 +548,12 @@ class _SessionCard extends StatelessWidget {
final l = AppLocalizations.of(context);
final nativeClient = session.isNativeClient;
final bg = isCurrent ? context.colors.accentSoft : context.colors.card;
final borderColor = isCurrent ? context.colors.primary : context.colors.border;
final borderColor = isCurrent
? context.colors.primary
: context.colors.border;
final borderWidth = isCurrent ? 2.0 : 1.0;
final ipLabel =
session.ip.isEmpty ? '-' : session.ip;
final ipLabel = session.ip.isEmpty ? '-' : session.ip;
final location = session.location;
return Container(
@@ -568,9 +570,7 @@ class _SessionCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
nativeClient
? Icons.smartphone_rounded
: Icons.monitor_rounded,
nativeClient ? Icons.smartphone_rounded : Icons.monitor_rounded,
size: 18,
color: context.colors.primary,
),
@@ -600,10 +600,7 @@ class _SessionCard extends StatelessWidget {
],
),
),
_StatusBadge(
label: _statusLabel(l),
tone: _statusTone(),
),
_StatusBadge(label: _statusLabel(l), tone: _statusTone()),
],
),
const SizedBox(height: 10),
@@ -696,8 +693,14 @@ class _StatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final (bg, fg) = switch (tone) {
_StatusTone.success => (context.colors.success, context.colors.fontOnPrimary),
_StatusTone.primary => (context.colors.accentSoft, context.colors.primary),
_StatusTone.success => (
context.colors.success,
context.colors.fontOnPrimary,
),
_StatusTone.primary => (
context.colors.accentSoft,
context.colors.primary,
),
_StatusTone.muted => (context.colors.chip, context.colors.muted),
};
return Container(