Files
pangolin/client/lib/state/auth_provider.dart
wangjia 34d980d875 feat(client): 设备数超限挡板 DeviceLimitScreen(#16 前端)
登录响应带 device_limit 时,顶层路由把用户挡在「移除设备」页,降到上限内才进主界面。

- auth_api.dart:AuthTokens 解析 device_limit;新增 DeviceLimit/DeviceBrief 模型
- auth_provider.dart:deviceLimitProvider(挡板状态);auth_screen 登录后置入
- main.dart:isLoggedIn 后若 deviceLimit!=null → DeviceLimitScreen(挡 HomeShell)
- screens/device_limit_screen.dart(新):设备列表(本机标注不可删)+ 每台移除
  + 一键「移除最久未用」+ 退出登录;降到上限内自动清挡板放行(复用 devicesProvider.remove)
- l10n:deviceLimitTitle/Desc/RemoveOldest(zh/en);顺带修 devicesSub 文案 5→3 台

analyze 干净;client 全量测试 161 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:25:14 +08:00

107 lines
4.3 KiB
Dart

// auth_provider.dart — 认证状态(JWT 令牌生命周期)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/api_config.dart';
import '../services/auth_api.dart';
import '../services/token_store.dart';
// ── 状态 ────────────────────────────────────────────────────────────
class AuthState {
const AuthState({this.accessToken, this.isLoading = false});
final String? accessToken;
final bool isLoading;
bool get isLoggedIn => accessToken != null && accessToken!.isNotEmpty;
AuthState copyWith({String? accessToken, bool? isLoading}) => AuthState(
accessToken: accessToken ?? this.accessToken,
isLoading: isLoading ?? this.isLoading,
);
}
// ── 状态机 ───────────────────────────────────────────────────────────
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier(this._store, {AuthApi? api})
: _api = api ?? AuthApi(baseUrl: kApiBaseUrl),
super(const AuthState(isLoading: true)) {
_loadFromStore();
}
final TokenStore _store;
final AuthApi _api;
/// 进行中的刷新(single-flight)。refresh token 是「单次使用 + 轮换」的——
/// 服务端每次刷新会作废旧 JTI、发新的。冷启动/进页面会并发打多个接口,过期后
/// 会同时多次 401 → 各自拿同一个旧 refresh token 去刷新:第一个成功轮换,其余
/// 拿已作废的 JTI 必被拒 → 误触发 logout 把用户踢下线。用一个共享的 in-flight
/// future 合并并发刷新,旧 token 只被消费一次。
Future<bool>? _refreshInFlight;
Future<void> _loadFromStore() async {
try {
final token = await _store.loadAccessToken();
state = AuthState(accessToken: token);
} catch (_) {
// FlutterSecureStorage 在测试环境 / 未初始化时抛异常,视为未登录。
state = const AuthState();
}
}
/// 登录 / 注册成功后保存令牌。
Future<void> saveTokens(AuthTokens tokens) async {
await _store.saveTokens(
access: tokens.accessToken,
refresh: tokens.refreshToken,
);
state = AuthState(accessToken: tokens.accessToken);
}
/// 用存储的 refresh token 刷新令牌(供 ApiClient 在 401 时调用)。
/// 成功写入新令牌并更新状态返回 true;无 refresh token / 刷新失败返回 false。
/// single-flight:并发调用共用同一次刷新,避免单次使用的 refresh token 被多个
/// 并发 401 重复消费 → 误判失效把用户踢下线。
Future<bool> refresh() {
return _refreshInFlight ??= _doRefresh().whenComplete(() => _refreshInFlight = null);
}
Future<bool> _doRefresh() async {
final rt = await _store.loadRefreshToken();
if (rt == null || rt.isEmpty) return false;
try {
await saveTokens(await _api.refresh(rt));
return true;
} on AuthApiException catch (e) {
// 服务端明确拒绝(refresh 过期/失效,超出 7 天)→ 退出登录回登录页;
// 网络错误(statusCode<=0)则保留会话,稍后重试。
if (e.statusCode > 0) await logout();
return false;
} catch (_) {
return false;
}
}
/// 退出登录:清除本地令牌。
Future<void> logout() async {
await _store.clear();
state = const AuthState();
}
}
// ── Providers ────────────────────────────────────────────────────────
/// 可在测试中 override,注入 stub TokenStore(避免 FlutterSecureStorage 平台依赖)。
final tokenStoreProvider = Provider<TokenStore>((_) => const TokenStore());
/// 登录时活跃设备超套餐上限的信号(来自登录响应 device_limit)。非 null → 顶层路由
/// 挡在「移除设备」页(登录已成功、token 已发,但先降到上限内才进主界面)。用户在
/// DeviceLimitScreen 移除设备降到上限内后置回 null,放行进主界面;登出时也清空。
final deviceLimitProvider = StateProvider<DeviceLimit?>((_) => null);
final authProvider =
StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(ref.watch(tokenStoreProvider)),
);