Files
app/lib/presentation/pages/settings/security_settings_page.dart
2026-05-15 09:03:09 +08:00

602 lines
21 KiB
Dart

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<SecuritySettingsPage> createState() => _SecuritySettingsPageState();
}
class _SecuritySettingsPageState extends State<SecuritySettingsPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<UserSettingProvider>().loadSettings();
});
}
@override
Widget build(BuildContext context) {
final provider = context.watch<UserSettingProvider>();
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<Widget> 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<void> _showChangePasswordDialog(BuildContext context) async {
final currentCtrl = TextEditingController();
final newCtrl = TextEditingController();
final confirmCtrl = TextEditingController();
final formKey = GlobalKey<FormState>();
bool obscureCurrent = true;
bool obscureNew = true;
final confirmed = await showDialog<bool>(
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<UserSettingProvider>().changePassword(
currentPassword: currentCtrl.text,
newPassword: newCtrl.text,
);
if (!mounted) return;
if (success) {
ToastHelper.success('密码已修改');
} else {
ToastHelper.failure('修改密码失败,请检查当前密码是否正确');
}
}
// ---- 启用2FA ----
Future<void> _showEnable2FADialog(BuildContext context) async {
final codeCtrl = TextEditingController();
String secret = '';
try {
// 先获取 TOTP secret
final secretJsonString = await context.read<UserSettingProvider>().prepare2FA();
final Map<String, dynamic> 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<AuthProvider>();
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<UserSettingProvider>().enable2FA(code);
if (!mounted) return;
if (success) {
ToastHelper.success('两步验证已启用');
} else {
ToastHelper.failure('启用失败,请检查验证码是否正确');
}
},
child: const Text('启用'),
),
],
),
);
}
// ---- 禁用2FA ----
Future<void> _showDisable2FADialog(BuildContext context) async {
final codeCtrl = TextEditingController();
final confirmed = await showDialog<bool>(
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<UserSettingProvider>().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<void> _deletePasskey(BuildContext context, PasskeyModel passkey) async {
final confirmed = await showDialog<bool>(
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<UserSettingProvider>().loadSettings();
if (mounted) ToastHelper.success('Passkey 已删除');
} catch (e) {
if (mounted) ToastHelper.failure('删除失败: $e');
}
}
// ---- 已关联账号 ----
List<Widget> _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<void> _unlinkOpenId(BuildContext context, OpenIdProvider oid) async {
final confirmed = await showDialog<bool>(
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<UserSettingProvider>().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<Widget> _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<void> _revokeOAuth(BuildContext context, OAuthGrant grant) async {
final confirmed = await showDialog<bool>(
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<UserSettingProvider>().revokeOAuthGrant(grant.clientId);
if (!mounted) return;
if (success) {
ToastHelper.success('已撤销 ${grant.clientName} 的授权');
} else {
ToastHelper.failure('撤销授权失败');
}
}
// ---- 登录活动 ----
List<Widget> _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')}';
}
}