✨ E2EE and Keypair
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/keypair.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
@ -49,6 +50,13 @@ class _AccountScreenWidgetState extends State<AccountScreenWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthProvider>();
|
||||
final keypair = context.read<KeypairProvider>();
|
||||
|
||||
final actionItems = [
|
||||
(const Icon(Icons.color_lens), AppLocalizations.of(context)!.personalize, 'account.personalize'),
|
||||
(const Icon(Icons.diversity_1), AppLocalizations.of(context)!.friend, 'account.friend'),
|
||||
(const Icon(Icons.key), AppLocalizations.of(context)!.keypair, 'account.keypair'),
|
||||
];
|
||||
|
||||
if (_isAuthorized) {
|
||||
return Column(
|
||||
@ -57,28 +65,23 @@ class _AccountScreenWidgetState extends State<AccountScreenWidget> {
|
||||
padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24),
|
||||
child: NameCard(),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
|
||||
leading: const Icon(Icons.color_lens),
|
||||
title: Text(AppLocalizations.of(context)!.personalize),
|
||||
onTap: () {
|
||||
widget.onSelect('account.personalize');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
|
||||
leading: const Icon(Icons.diversity_1),
|
||||
title: Text(AppLocalizations.of(context)!.friend),
|
||||
onTap: () {
|
||||
widget.onSelect('account.friend');
|
||||
},
|
||||
),
|
||||
...(actionItems.map(
|
||||
(x) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
|
||||
leading: x.$1,
|
||||
title: Text(x.$2),
|
||||
onTap: () {
|
||||
widget.onSelect(x.$3);
|
||||
},
|
||||
),
|
||||
)),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
|
||||
leading: const Icon(Icons.logout),
|
||||
title: Text(AppLocalizations.of(context)!.signOut),
|
||||
onTap: () {
|
||||
auth.signoff();
|
||||
keypair.clearKeys();
|
||||
setState(() {
|
||||
_isAuthorized = false;
|
||||
});
|
||||
|
191
lib/screens/account/keypair.dart
Normal file
191
lib/screens/account/keypair.dart
Normal file
@ -0,0 +1,191 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/keypair.dart';
|
||||
import 'package:solian/providers/keypair.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class KeypairScreen extends StatelessWidget {
|
||||
const KeypairScreen({super.key});
|
||||
|
||||
Widget getIcon(KeypairProvider provider, Keypair item) {
|
||||
if (item.id == provider.activeKeyId) {
|
||||
return const Icon(Icons.check_box);
|
||||
} else if (item.isOwned) {
|
||||
return const Icon(Icons.check_box_outlined);
|
||||
} else {
|
||||
return const Icon(Icons.key);
|
||||
}
|
||||
}
|
||||
|
||||
void importKeys(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final input = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.import),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context)!.keypairImportHint),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.keypairSecretCode,
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(AppLocalizations.of(context)!.next),
|
||||
onPressed: () {
|
||||
Navigator.pop(context, controller.text);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
|
||||
|
||||
if (input == null || input.isEmpty) return;
|
||||
|
||||
context.read<KeypairProvider>().importKeys(input);
|
||||
}
|
||||
|
||||
void exportKeys(BuildContext context) {
|
||||
showModalBottomSheet(context: context, builder: (context) => const KeypairExportWidget());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keypair = context.watch<KeypairProvider>();
|
||||
final keys = keypair.keys.values.toList();
|
||||
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.keypair,
|
||||
hideDrawer: true,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.generating_tokens),
|
||||
onPressed: () {
|
||||
final result = keypair.generateAESKey();
|
||||
keypair.setActiveKey(result.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.keypairGenerated),
|
||||
));
|
||||
},
|
||||
),
|
||||
appBarActions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.upload),
|
||||
tooltip: AppLocalizations.of(context)!.import,
|
||||
onPressed: () => importKeys(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
tooltip: AppLocalizations.of(context)!.export,
|
||||
onPressed: () => exportKeys(context),
|
||||
),
|
||||
],
|
||||
body: ListView.builder(
|
||||
itemCount: keys.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = keys[index];
|
||||
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
|
||||
return Dismissible(
|
||||
key: Key(randomId.toString()),
|
||||
background: Container(
|
||||
color: Colors.teal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: const Icon(Icons.check, color: Colors.white),
|
||||
),
|
||||
direction: keypair.activeKeyId != element.id && element.isOwned
|
||||
? DismissDirection.horizontal
|
||||
: DismissDirection.none,
|
||||
child: ListTile(
|
||||
leading: getIcon(keypair, element),
|
||||
title: Text('${element.algorithm.toUpperCase()} Key'),
|
||||
subtitle: Text(element.id.toUpperCase()),
|
||||
),
|
||||
onDismissed: (_) {
|
||||
keypair.setActiveKey(element.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class KeypairExportWidget extends StatelessWidget {
|
||||
const KeypairExportWidget({super.key});
|
||||
|
||||
String getEncodedContent(BuildContext context) {
|
||||
final keypair = context.read<KeypairProvider>();
|
||||
return utf8.fuse(base64).encode(jsonEncode(
|
||||
keypair.keys.values.map((x) => x.toJson()).toList(),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 640,
|
||||
child: ListView(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 20, top: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.export,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 20),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.keypairExportHint,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Markdown(
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: '```\n${getEncodedContent(context)}\n```',
|
||||
padding: const EdgeInsets.all(0),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
codeblockPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -15,9 +15,9 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChannelEditorScreen extends StatefulWidget {
|
||||
final Channel? editing;
|
||||
final String? realm;
|
||||
final String realm;
|
||||
|
||||
const ChannelEditorScreen({super.key, this.editing, this.realm});
|
||||
const ChannelEditorScreen({super.key, this.editing, this.realm = 'global'});
|
||||
|
||||
@override
|
||||
State<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
|
||||
@ -28,6 +28,8 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
bool _isEncrypted = false;
|
||||
|
||||
bool _isSubmitting = false;
|
||||
|
||||
Future<void> applyChannel(BuildContext context) async {
|
||||
@ -39,9 +41,10 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final scope = widget.realm.isNotEmpty ? widget.realm : 'global';
|
||||
final uri = widget.editing == null
|
||||
? getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}')
|
||||
: getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}/${widget.editing!.id}');
|
||||
? getRequestUri('messaging', '/api/channels/$scope')
|
||||
: getRequestUri('messaging', '/api/channels/$scope/${widget.editing!.id}');
|
||||
|
||||
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
|
||||
req.headers['Content-Type'] = 'application/json';
|
||||
@ -49,6 +52,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
'alias': _aliasController.value.text.toLowerCase(),
|
||||
'name': _nameController.value.text,
|
||||
'description': _descriptionController.value.text,
|
||||
'is_encrypted': _isEncrypted,
|
||||
});
|
||||
|
||||
var res = await Response.fromStream(await auth.client!.send(req));
|
||||
@ -57,7 +61,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
context.showErrorDialog(message);
|
||||
} else {
|
||||
if (SolianRouter.router.canPop()) {
|
||||
SolianRouter.router.pop(true);
|
||||
SolianRouter.router.pop(_aliasController.value.text.toLowerCase());
|
||||
}
|
||||
}
|
||||
setState(() => _isSubmitting = false);
|
||||
@ -79,6 +83,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
_aliasController.text = widget.editing!.alias;
|
||||
_nameController.text = widget.editing!.name;
|
||||
_descriptionController.text = widget.editing!.description;
|
||||
_isEncrypted = widget.editing!.isEncrypted;
|
||||
}
|
||||
|
||||
super.initState();
|
||||
@ -177,6 +182,15 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3),
|
||||
CheckboxListTile(
|
||||
title: Text(AppLocalizations.of(context)!.chatChannelEncryptedLabel),
|
||||
value: _isEncrypted,
|
||||
onChanged: (widget.editing?.isEncrypted ?? false) ? null : (newValue) {
|
||||
setState(() => _isEncrypted = newValue ?? false);
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -255,6 +255,7 @@ class _ChatWidgetState extends State<ChatWidget> {
|
||||
channel: widget.alias,
|
||||
editing: _editingItem,
|
||||
replying: _replyingItem,
|
||||
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
|
||||
onReset: () => setState(() {
|
||||
_editingItem = null;
|
||||
_replyingItem = null;
|
||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/chat/channel_deletion.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
@ -23,11 +24,12 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
||||
void promptLeaveChannel() async {
|
||||
final did = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ChannelDeletion(
|
||||
channel: widget.channel,
|
||||
realm: widget.realm,
|
||||
isOwned: _isOwned,
|
||||
),
|
||||
builder: (context) =>
|
||||
ChannelDeletion(
|
||||
channel: widget.channel,
|
||||
realm: widget.realm,
|
||||
isOwned: _isOwned,
|
||||
),
|
||||
);
|
||||
if (did == true && SolianRouter.router.canPop()) {
|
||||
SolianRouter.router.pop('disposed');
|
||||
@ -50,14 +52,23 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final chat = context.read<ChatProvider>();
|
||||
|
||||
final authorizedItems = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: Text(AppLocalizations.of(context)!.settings),
|
||||
onTap: () async {
|
||||
SolianRouter.router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) {
|
||||
if (did == true) {
|
||||
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
|
||||
SolianRouter.router
|
||||
.pushNamed(
|
||||
'chat.channel.editor',
|
||||
extra: widget.channel,
|
||||
queryParameters: widget.realm != 'global' ? {'realm': widget.realm} : {},
|
||||
)
|
||||
.then((resp) {
|
||||
if (resp != null) {
|
||||
chat.fetchChannel(context, auth, resp as String, widget.realm);
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -81,8 +92,14 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(widget.channel.name, style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodyLarge),
|
||||
Text(widget.channel.description, style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodySmall),
|
||||
]),
|
||||
)
|
||||
],
|
||||
|
@ -124,11 +124,10 @@ class _ChatListWidgetState extends State<ChatListWidget> {
|
||||
title: Text(element.name),
|
||||
subtitle: Text(element.description),
|
||||
onTap: () async {
|
||||
String? result;
|
||||
if (['chat.channel', 'realms.chat.channel'].contains(SolianRouter.currentRoute.name)) {
|
||||
chat.fetchChannel(context, auth, element.alias, widget.realm!);
|
||||
} else {
|
||||
result = await SolianRouter.router.pushNamed(
|
||||
SolianRouter.router.pushNamed(
|
||||
widget.realm == null ? 'chat.channel' : 'realms.chat.channel',
|
||||
pathParameters: {
|
||||
'channel': element.alias,
|
||||
@ -136,10 +135,6 @@ class _ChatListWidgetState extends State<ChatListWidget> {
|
||||
},
|
||||
);
|
||||
}
|
||||
switch (result) {
|
||||
case 'refresh':
|
||||
fetchChannels();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
|
@ -37,7 +37,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
markList.add(element.id);
|
||||
}
|
||||
|
||||
nty.clearRealtime();
|
||||
nty.clearRealtimeNotifications();
|
||||
|
||||
if(markList.isNotEmpty) {
|
||||
var uri = getRequestUri('passport', '/api/notifications/batch/read');
|
||||
|
Reference in New Issue
Block a user