import 'dart:convert'; import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../../../data/models/user_setting_model.dart'; import '../../../services/user_setting_service.dart'; import '../../providers/auth_provider.dart'; import '../../providers/user_setting_provider.dart'; import '../../widgets/toast_helper.dart'; import '../../widgets/desktop_constrained.dart'; /// 安全设置页 class SecuritySettingsPage extends StatefulWidget { const SecuritySettingsPage({super.key}); @override State createState() => _SecuritySettingsPageState(); } class _SecuritySettingsPageState extends State { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().loadSettings(); }); } @override Widget build(BuildContext context) { final provider = context.watch(); final settings = provider.settings; return Scaffold( appBar: AppBar(title: const Text('安全设置')), body: DesktopConstrained( child: ListView( children: [ _buildSection( title: '密码', children: [ ListTile( leading: const Icon(Icons.lock_outline), title: const Text('修改密码'), subtitle: Text(settings?.passwordless == true ? '当前使用无密码登录' : '修改账户密码'), trailing: const Icon(Icons.chevron_right), onTap: () => _showChangePasswordDialog(context), ), ], ), _buildSection( title: '两步验证', children: [ SwitchListTile( secondary: Icon( settings?.twoFaEnabled == true ? Icons.shield : Icons.shield_outlined, ), title: const Text('两步验证 (2FA)'), subtitle: Text( settings?.twoFaEnabled == true ? '已启用 — 登录时需要验证码' : '未启用', ), value: settings?.twoFaEnabled ?? false, onChanged: (value) { if (value) { _showEnable2FADialog(context); } else { _showDisable2FADialog(context); } }, ), ], ), _buildSection( title: 'Passkey', children: [ ListTile( leading: const Icon(Icons.vpn_key_outlined), title: Text('Passkey (${settings?.passkeys.length ?? 0})'), subtitle: settings?.passkeys.isEmpty ?? true ? const Text('未注册任何 Passkey') : Text('已注册 ${settings!.passkeys.length} 个 Passkey'), trailing: const Icon(Icons.chevron_right), onTap: () => _showPasskeyList(context, settings), ), ], ), _buildSection( title: '已关联账号', children: _buildOpenIdTiles(context, settings), ), _buildSection( title: '已授权应用', children: _buildOAuthGrantTiles(context, settings), ), if (settings?.loginActivity.isNotEmpty ?? false) _buildSection( title: '登录活动', children: _buildLoginActivityTiles(settings!), ), ], ), ), ); } Widget _buildSection({required String title, required List children}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( title, style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), ), ), Card( margin: const EdgeInsets.symmetric(horizontal: 16), child: Column(children: children), ), ], ), ); } // ---- 修改密码 ---- Future _showChangePasswordDialog(BuildContext context) async { final currentCtrl = TextEditingController(); final newCtrl = TextEditingController(); final confirmCtrl = TextEditingController(); final formKey = GlobalKey(); bool obscureCurrent = true; bool obscureNew = true; final confirmed = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( title: const Text('修改密码'), content: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( controller: currentCtrl, obscureText: obscureCurrent, decoration: InputDecoration( labelText: '当前密码', suffixIcon: IconButton( icon: Icon(obscureCurrent ? Icons.visibility_off : Icons.visibility), onPressed: () => setDialogState(() => obscureCurrent = !obscureCurrent), ), ), validator: (v) => (v == null || v.isEmpty) ? '请输入当前密码' : null, ), const SizedBox(height: 12), TextFormField( controller: newCtrl, obscureText: obscureNew, decoration: InputDecoration( labelText: '新密码', suffixIcon: IconButton( icon: Icon(obscureNew ? Icons.visibility_off : Icons.visibility), onPressed: () => setDialogState(() => obscureNew = !obscureNew), ), ), validator: (v) { if (v == null || v.isEmpty) return '请输入新密码'; if (v.length < 6) return '密码至少6个字符'; if (v.length > 128) return '密码不能超过128个字符'; return null; }, ), const SizedBox(height: 12), TextFormField( controller: confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: '确认新密码'), validator: (v) { if (v != newCtrl.text) return '两次输入的密码不一致'; return null; }, ), ], ), ), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), FilledButton( onPressed: () { if (formKey.currentState!.validate()) { Navigator.of(ctx).pop(true); } }, child: const Text('确定'), ), ], ), ), ); if (confirmed != true || !mounted) return; if (!context.mounted) return; final success = await context.read().changePassword( currentPassword: currentCtrl.text, newPassword: newCtrl.text, ); if (!mounted) return; if (success) { ToastHelper.success('密码已修改'); } else { ToastHelper.failure('修改密码失败,请检查当前密码是否正确'); } } // ---- 启用2FA ---- Future _showEnable2FADialog(BuildContext context) async { final codeCtrl = TextEditingController(); String secret = ''; try { // 先获取 TOTP secret final secretJsonString = await context.read().prepare2FA(); final Map secretMap = jsonDecode(secretJsonString!); secret = secretMap['data']; } catch (e) { secret = e.toString(); } AppLogger.d("2FA API Response --> $secret"); if (secret.isEmpty || !mounted) { ToastHelper.failure('获取2FA密钥失败'); return; } if (!context.mounted) return; final auth = context.read(); final userEmail = auth.user?.email ?? 'user'; final otpAuthUri = 'otpauth://totp/Cloudreve:$userEmail?secret=$secret&issuer=Cloudreve'; await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('启用两步验证'), content: SizedBox( width: MediaQuery.of(ctx).size.width >= 1000 ? MediaQuery.of(ctx).size.width * 0.4 : MediaQuery.of(ctx).size.width * 0.8, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ const Text('1. 使用验证器应用扫描二维码:'), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), ), child: QrImageView( data: otpAuthUri, version: QrVersions.auto, size: 180, backgroundColor: Colors.white, ), ), const SizedBox(height: 12), const Text('或手动输入密钥:'), const SizedBox(height: 4), Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Theme.of(ctx).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: SelectableText( textAlign: TextAlign.center, secret, style: const TextStyle(fontFamily: 'SourceCodePro', fontSize: 13), ), ), const SizedBox(height: 16), const Text('2. 输入验证器显示的6位验证码:'), const SizedBox(height: 8), TextField( controller: codeCtrl, keyboardType: TextInputType.number, textAlign: TextAlign.center, maxLength: 6, autofocus: true, decoration: const InputDecoration( labelText: '验证码', counterText: '', ), ), ], ), ), ), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')), FilledButton( onPressed: () async { final code = codeCtrl.text.trim(); if (code.length != 6) { ToastHelper.warning('请输入6位验证码'); return; } Navigator.of(ctx).pop(); final success = await context.read().enable2FA(code); if (!mounted) return; if (success) { ToastHelper.success('两步验证已启用'); } else { ToastHelper.failure('启用失败,请检查验证码是否正确'); } }, child: const Text('启用'), ), ], ), ); } // ---- 禁用2FA ---- Future _showDisable2FADialog(BuildContext context) async { final codeCtrl = TextEditingController(); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('禁用两步验证'), content: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('请输入当前验证器显示的6位验证码以确认禁用:'), const SizedBox(height: 12), TextField( controller: codeCtrl, keyboardType: TextInputType.number, maxLength: 6, autofocus: true, decoration: const InputDecoration(labelText: '验证码', counterText: ''), ), ], ), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), child: const Text('禁用'), ), ], ), ); if (confirmed != true || !mounted) return; if (!context.mounted) return; final success = await context.read().disable2FA(codeCtrl.text.trim()); if (!mounted) return; if (success) { ToastHelper.success('两步验证已禁用'); } else { ToastHelper.failure('禁用失败,请检查验证码是否正确'); } } // ---- Passkey 列表 ---- void _showPasskeyList(BuildContext context, UserSettingModel? settings) { final passkeys = settings?.passkeys ?? []; showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) => DraggableScrollableSheet( initialChildSize: 0.4, minChildSize: 0.2, maxChildSize: 0.7, expand: false, builder: (ctx, controller) => ListView( controller: controller, children: [ Padding( padding: const EdgeInsets.all(16), child: Text('Passkey 管理', style: Theme.of(ctx).textTheme.titleMedium), ), if (passkeys.isEmpty) const Padding( padding: EdgeInsets.all(32), child: Center(child: Text('暂无已注册的 Passkey', style: TextStyle(color: Colors.grey))), ) else ...passkeys.map((pk) => ListTile( leading: const Icon(Icons.vpn_key), title: Text(pk.name), subtitle: Text('创建于 ${_formatDate(pk.createdAt)}' '${pk.usedAt != null ? ' · 最后使用 ${_formatDate(pk.usedAt!)}' : ''}'), trailing: IconButton( icon: const Icon(Icons.delete_outline, color: Colors.red), onPressed: () => _deletePasskey(ctx, pk), ), )), // 注册新Passkey暂不实现,Flutter端WebAuthn支持有限 // 后续批次实现 ], ), ), ); } Future _deletePasskey(BuildContext context, PasskeyModel passkey) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('删除 Passkey'), content: Text('确定要删除 "${passkey.name}" 吗?'), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), child: const Text('删除'), ), ], ), ); if (confirmed != true || !mounted) return; try { await UserSettingService.instance.deletePasskey(passkey.id); if (!context.mounted) return; await context.read().loadSettings(); if (mounted) ToastHelper.success('Passkey 已删除'); } catch (e) { if (mounted) ToastHelper.failure('删除失败: $e'); } } // ---- 已关联账号 ---- List _buildOpenIdTiles(BuildContext context, UserSettingModel? settings) { final openIds = settings?.openId ?? []; if (openIds.isEmpty) { return [ ListTile( leading: const Icon(Icons.link_off), title: const Text('暂无关联账号'), subtitle: const Text('未关联任何第三方账号'), ), ]; } return openIds .map((oid) => ListTile( leading: Icon(_openIdIcon(oid.provider)), title: Text(oid.providerName), subtitle: Text('关联于 ${_formatDate(oid.linkedAt)}'), trailing: IconButton( icon: const Icon(Icons.link_off, color: Colors.red), onPressed: () => _unlinkOpenId(context, oid), ), )) .toList(); } Future _unlinkOpenId(BuildContext context, OpenIdProvider oid) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('解绑账号'), content: Text('确定要解绑 ${oid.providerName} 吗?'), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), child: const Text('解绑'), ), ], ), ); if (confirmed != true || !mounted) return; if (!context.mounted) return; final success = await context.read().unlinkOpenId(oid.provider); if (!mounted) return; if (success) { ToastHelper.success('已解绑 ${oid.providerName}'); } else { ToastHelper.failure('解绑失败'); } } IconData _openIdIcon(int provider) { switch (provider) { case 1: return Icons.chat_bubble; // QQ default: return Icons.login; } } // ---- OAuth 授权 ---- List _buildOAuthGrantTiles(BuildContext context, UserSettingModel? settings) { final grants = settings?.oauthGrants ?? []; if (grants.isEmpty) { return [ ListTile( leading: const Icon(Icons.apps_outage), title: const Text('暂无授权应用'), subtitle: const Text('未授权任何第三方应用'), ), ]; } return grants .map((grant) => ListTile( leading: grant.clientLogo != null ? CircleAvatar(backgroundImage: NetworkImage(grant.clientLogo!)) : const CircleAvatar(child: Icon(Icons.apps)), title: Text(grant.clientName), subtitle: Text(grant.lastUsedAt != null ? '最后使用 ${_formatDate(grant.lastUsedAt!)}' : '权限: ${grant.scopes.join(", ")}'), trailing: IconButton( icon: const Icon(Icons.delete_outline, color: Colors.red), onPressed: () => _revokeOAuth(context, grant), ), )) .toList(); } Future _revokeOAuth(BuildContext context, OAuthGrant grant) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('撤销授权'), content: Text('确定要撤销 "${grant.clientName}" 的授权吗?'), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), child: const Text('撤销'), ), ], ), ); if (confirmed != true || !mounted) return; if (!context.mounted) return; final success = await context.read().revokeOAuthGrant(grant.clientId); if (!mounted) return; if (success) { ToastHelper.success('已撤销 ${grant.clientName} 的授权'); } else { ToastHelper.failure('撤销授权失败'); } } // ---- 登录活动 ---- List _buildLoginActivityTiles(UserSettingModel settings) { final activities = settings.loginActivity; if (activities.isEmpty) { return [ const ListTile( leading: Icon(Icons.history), title: Text('暂无登录记录'), ), ]; } return activities.map((activity) { final icon = activity.success ? Icons.check_circle_outline : Icons.cancel_outlined; final iconColor = activity.success ? Colors.green : Colors.red; return ListTile( leading: Icon(icon, color: iconColor), title: Text(activity.loginMethodName), subtitle: Text( '${_formatDate(activity.createdAt)}' '\n${activity.os} · ${activity.browser}' '${activity.ip.isNotEmpty ? " · ${activity.ip}" : ""}', ), isThreeLine: true, ); }).toList(); } String _formatDate(DateTime date) { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } }