From 3395f3dbd0e572ef9247f4da9ce174549654d93a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 28 Jan 2025 00:52:44 +0800 Subject: [PATCH] :sparkles: Create auth factor --- assets/translations/en-US.json | 11 ++ assets/translations/zh-CN.json | 10 ++ lib/router.dart | 100 ++++++----- lib/screens/account.dart | 10 ++ lib/screens/account/factor_settings.dart | 201 +++++++++++++++++++++++ 5 files changed, 280 insertions(+), 52 deletions(-) create mode 100644 lib/screens/account/factor_settings.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 8a9ddf4..1abd005 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -29,6 +29,7 @@ "screenNotification": "Notification", "screenPostSearch": "Search Posts", "screenFriend": "Friends", + "screenFactorSettings": "Auth Factors", "dialogOkay": "Okay", "dialogCancel": "Cancel", "dialogConfirm": "Confirm", @@ -106,7 +107,15 @@ "loginEnterPassword": "Enter the code", "loginSuccess": "Logged in as {}", "authFactorPassword": "Password", + "authFactorPasswordDescription": "The password you set when you registered.", "authFactorEmail": "Email verification code", + "authFactorEmailDescription": "An one-time code sent to the email address you set when you registered.", + "authFactorTOTP": "Time-based OTP", + "authFactorTOTPDescription": "A one-time code generated by a TOTP authenticator such as Google Authenticator or Authy.", + "authFactorInAppNotify": "In-app notification", + "authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.", + "authFactorAdd": "Add a factor", + "authFactorAddSubtitle": "Provide another way to login your account.", "accountIntroTitle": "Hello there!", "accountIntroSubtitle": "Pick an option below to get started.", "accountLogout": "Logout", @@ -119,6 +128,8 @@ "accountSettingsSubtitle": "Manage your account and make it yours.", "accountProfileEdit": "Edit your profile", "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.", + "factorSettings": "Auth Factors", + "factorSettingsSubtitle": "Manage your authentication factors.", "accountProfileEditApplied": "Profile modification applied.", "publishersNew": "New Publisher", "publisherNewSubtitle": "Create a new publisher identity.", diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index af22be4..73e0e0a 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -90,7 +90,15 @@ "loginEnterPassword": "验证代码", "loginSuccess": "登录为 {}", "authFactorPassword": "密码", + "authFactorPasswordDescription": "注册时选择设置的密码。", "authFactorEmail": "电邮一次性验证码", + "authFactorEmailDescription": "由我们生成并发送到绑定的的电子邮箱的一次性验证码。", + "authFactorTOTP": "时序验证码", + "authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等验证器生成的一次性验证码。", + "authFactorInAppNotify": "应用内通知验证码", + "authFactorInAppNotifyDescription": "通过站内通知推送的一次性验证码。", + "authFactorAdd": "添加新验证因子", + "authFactorAddSubtitle": "给你的帐户登陆时提供另一个方案。", "accountIntroTitle": "喜欢您来!", "accountIntroSubtitle": "登陆以探索更广大的世界。", "accountLogout": "退出登录", @@ -103,6 +111,8 @@ "accountSettingsSubtitle": "管理你的帐号并让它更好的服务你。", "accountProfileEdit": "编辑资料", "accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。", + "factorSettings": "验证因子", + "factorSettingsSubtitle": "管理你的登陆验证方式。", "accountProfileEditApplied": "个人资料修改已被应用。", "publishersNew": "新发布者", "publisherNewSubtitle": "创建一个新的公共身份。", diff --git a/lib/router.dart b/lib/router.dart index 7f7787a..f837da4 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:surface/screens/abuse_report.dart'; import 'package:surface/screens/account.dart'; import 'package:surface/screens/account/account_settings.dart'; +import 'package:surface/screens/account/factor_settings.dart'; import 'package:surface/screens/account/profile_page.dart'; import 'package:surface/screens/account/profile_edit.dart'; import 'package:surface/screens/account/publishers/publisher_edit.dart'; @@ -97,47 +98,47 @@ final _appRoutes = [ ), ], ), - GoRoute( - path: '/account', - name: 'account', - builder: (context, state) => const AccountScreen(), - routes: [ - GoRoute( - path: '/settings', - name: 'accountSettings', - builder: (context, state) => AccountSettingsScreen(), + GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [ + GoRoute( + path: '/settings', + name: 'accountSettings', + builder: (context, state) => AccountSettingsScreen(), + ), + GoRoute( + path: '/settings/factors', + name: 'factorSettings', + builder: (context, state) => FactorSettingsScreen(), + ), + GoRoute( + path: '/profile/edit', + name: 'accountProfileEdit', + builder: (context, state) => ProfileEditScreen(), + ), + GoRoute( + path: '/publishers', + name: 'accountPublishers', + builder: (context, state) => PublisherScreen(), + ), + GoRoute( + path: '/publishers/new', + name: 'accountPublisherNew', + builder: (context, state) => AccountPublisherNewScreen(), + ), + GoRoute( + path: '/publishers/edit/:name', + name: 'accountPublisherEdit', + builder: (context, state) => AccountPublisherEditScreen( + name: state.pathParameters['name']!, ), - GoRoute( - path: '/profile/edit', - name: 'accountProfileEdit', - builder: (context, state) => ProfileEditScreen(), + ), + GoRoute( + path: '/:name', + name: 'accountProfilePage', + pageBuilder: (context, state) => NoTransitionPage( + child: UserScreen(name: state.pathParameters['name']!), ), - GoRoute( - path: '/publishers', - name: 'accountPublishers', - builder: (context, state) => PublisherScreen(), - ), - GoRoute( - path: '/publishers/new', - name: 'accountPublisherNew', - builder: (context, state) => AccountPublisherNewScreen(), - ), - GoRoute( - path: '/publishers/edit/:name', - name: 'accountPublisherEdit', - builder: (context, state) => AccountPublisherEditScreen( - name: state.pathParameters['name']!, - ), - ), - GoRoute( - path: '/:name', - name: 'accountProfilePage', - pageBuilder: (context, state) => NoTransitionPage( - child: UserScreen(name: state.pathParameters['name']!), - ), - ), - ] - ), + ), + ]), GoRoute( path: '/chat', name: 'chat', @@ -198,20 +199,15 @@ final _appRoutes = [ ), ], ), - GoRoute( - path: '/news', - name: 'news', - builder: (context, state) => const NewsScreen(), - routes: [ - GoRoute( - path: '/:hash', - name: 'newsDetail', - builder: (context, state) => NewsDetailScreen( - hash: state.pathParameters['hash']!, - ), + GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [ + GoRoute( + path: '/:hash', + name: 'newsDetail', + builder: (context, state) => NewsDetailScreen( + hash: state.pathParameters['hash']!, ), - ] - ), + ), + ]), GoRoute( path: '/album', name: 'album', diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 0ac8adf..69c0442 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -134,6 +134,16 @@ class _AuthorizedAccountScreen extends StatelessWidget { GoRouter.of(context).pushNamed('abuseReport'); }, ), + ListTile( + title: Text('factorSettings').tr(), + subtitle: Text('factorSettingsSubtitle').tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.lock), + trailing: const Icon(Symbols.chevron_right), + onTap: () { + GoRouter.of(context).pushNamed('factorSettings'); + }, + ), ListTile( title: Text('accountSettings').tr(), subtitle: Text('accountSettingsSubtitle').tr(), diff --git a/lib/screens/account/factor_settings.dart b/lib/screens/account/factor_settings.dart new file mode 100644 index 0000000..45385ab --- /dev/null +++ b/lib/screens/account/factor_settings.dart @@ -0,0 +1,201 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/types/auth.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; +import 'package:surface/widgets/navigation/app_scaffold.dart'; + +final Map _kFactorTypes = { + 0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), + 1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email), + 2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer), + 3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active), +}; + +class FactorSettingsScreen extends StatefulWidget { + const FactorSettingsScreen({super.key}); + + @override + State createState() => _FactorSettingsScreenState(); +} + +class _FactorSettingsScreenState extends State { + List? _factors; + + Future _fetchFactors() async { + try { + final sn = context.read(); + final resp = await sn.client.get('/cgi/id/users/me/factors'); + _factors = List.from( + resp.data?.map((e) => SnAuthFactor.fromJson(e as Map)).toList() ?? [], + ); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() {}); + } + } + + @override + void initState() { + super.initState(); + _fetchFactors(); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + appBar: AppBar( + leading: PageBackButton(), + title: Text('screenFactorSettings').tr(), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LoadingIndicator( + isActive: _factors == null, + ), + ListTile( + title: Text('authFactorAdd').tr(), + subtitle: Text('authFactorAddSubtitle').tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: const Icon(Symbols.add), + trailing: const Icon(Symbols.chevron_right), + onTap: () { + showDialog( + context: context, + builder: (context) => _FactorNewDialog( + currentlyHave: _factors!, + ), + ).then((val) { + if (val == true) _fetchFactors(); + }); + }, + ), + const Divider(height: 1), + Expanded( + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: RefreshIndicator( + onRefresh: _fetchFactors, + child: ListView.builder( + itemCount: _factors?.length ?? 0, + itemBuilder: (context, idx) { + final ele = _factors![idx]; + return ListTile( + title: Text(_kFactorTypes[ele.type]!.$1).tr(), + subtitle: Text(_kFactorTypes[ele.type]!.$2).tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Icon(_kFactorTypes[ele.type]!.$3), + ); + }, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _FactorNewDialog extends StatefulWidget { + final List currentlyHave; + + const _FactorNewDialog({required this.currentlyHave}); + + @override + State<_FactorNewDialog> createState() => _FactorNewDialogState(); +} + +class _FactorNewDialogState extends State<_FactorNewDialog> { + int? _factorType; + bool _isBusy = false; + + Future _submit() async { + try { + setState(() => _isBusy = true); + final sn = context.read(); + final resp = await sn.client.post('/cgi/id/users/me/factors', data: { + 'type': _factorType, + }); + // TODO show qrcode when creating totp factor + if (!mounted) return; + Navigator.of(context).pop(true); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('authFactorAdd').tr(), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonHideUnderline( + child: DropdownButton2( + hint: Text( + 'Select Item', + style: TextStyle( + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + value: _factorType, + items: _kFactorTypes.entries.map( + (ele) { + final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key); + return DropdownMenuItem( + enabled: !contains, + value: ele.key, + child: Text( + ele.value.$1.tr(), + style: const TextStyle( + fontSize: 14, + ), + ).opacity(contains ? 0.75 : 1), + ); + }, + ).toList(), + onChanged: (val) => setState(() { + _factorType = val; + }), + buttonStyleData: ButtonStyleData( + height: 50, + padding: const EdgeInsets.only(left: 14, right: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Theme.of(context).dividerColor, + ), + ), + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: _isBusy ? null : () => Navigator.of(context).pop(), + child: Text('dialogCancel').tr(), + ), + TextButton( + onPressed: _isBusy ? null : () => _submit(), + child: Text('dialogConfirm').tr(), + ), + ], + ); + } +}