feat(useragent): bootstrap / unseal flow implementattion
This commit is contained in:
408
useragent/lib/screens/vault_setup.dart
Normal file
408
useragent/lib/screens/vault_setup.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:arbiter/features/connection/connection.dart';
|
||||
import 'package:arbiter/proto/user_agent.pbenum.dart';
|
||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||
import 'package:arbiter/providers/vault_state.dart';
|
||||
import 'package:arbiter/router.gr.dart';
|
||||
import 'package:arbiter/widgets/bottom_popup.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sizer/sizer.dart';
|
||||
|
||||
@RoutePage()
|
||||
class VaultSetupScreen extends HookConsumerWidget {
|
||||
const VaultSetupScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vaultState = ref.watch(vaultStateProvider);
|
||||
final bootstrapPasswordController = useTextEditingController();
|
||||
final bootstrapConfirmController = useTextEditingController();
|
||||
final unsealPasswordController = useTextEditingController();
|
||||
final errorText = useState<String?>(null);
|
||||
final isSubmitting = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
if (vaultState.asData?.value == VaultState.VAULT_STATE_UNSEALED) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
context.router.replace(const DashboardRouter());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [context, vaultState.asData?.value]);
|
||||
|
||||
Future<void> refreshVaultState() async {
|
||||
ref.invalidate(vaultStateProvider);
|
||||
await ref.read(vaultStateProvider.future);
|
||||
}
|
||||
|
||||
Future<void> submitBootstrap() async {
|
||||
final password = bootstrapPasswordController.text;
|
||||
final confirmation = bootstrapConfirmController.text;
|
||||
|
||||
if (password.isEmpty || confirmation.isEmpty) {
|
||||
errorText.value = 'Enter the password twice.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password != confirmation) {
|
||||
errorText.value = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showBottomPopup<bool>(
|
||||
context: context,
|
||||
builder: (popupContext) {
|
||||
return _WarningPopup(
|
||||
title: 'Bootstrap vault?',
|
||||
body:
|
||||
'This password cannot be recovered. If you lose it, the vault cannot be unsealed.',
|
||||
confirmLabel: 'Bootstrap',
|
||||
onCancel: () => Navigator.of(popupContext).pop(false),
|
||||
onConfirm: () => Navigator.of(popupContext).pop(true),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
errorText.value = null;
|
||||
isSubmitting.value = true;
|
||||
|
||||
try {
|
||||
final connection = await ref.read(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
throw Exception('Not connected to the server.');
|
||||
}
|
||||
|
||||
final result = await bootstrapVault(connection, password);
|
||||
switch (result) {
|
||||
case BootstrapResult.BOOTSTRAP_RESULT_SUCCESS:
|
||||
bootstrapPasswordController.clear();
|
||||
bootstrapConfirmController.clear();
|
||||
await refreshVaultState();
|
||||
break;
|
||||
case BootstrapResult.BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED:
|
||||
errorText.value =
|
||||
'The vault was already bootstrapped. Refreshing vault state.';
|
||||
await refreshVaultState();
|
||||
break;
|
||||
case BootstrapResult.BOOTSTRAP_RESULT_INVALID_KEY:
|
||||
case BootstrapResult.BOOTSTRAP_RESULT_UNSPECIFIED:
|
||||
errorText.value = 'Failed to bootstrap the vault.';
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
errorText.value = _formatVaultError(error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submitUnseal() async {
|
||||
final password = unsealPasswordController.text;
|
||||
if (password.isEmpty) {
|
||||
errorText.value = 'Enter the vault password.';
|
||||
return;
|
||||
}
|
||||
|
||||
errorText.value = null;
|
||||
isSubmitting.value = true;
|
||||
|
||||
try {
|
||||
final connection = await ref.read(connectionManagerProvider.future);
|
||||
if (connection == null) {
|
||||
throw Exception('Not connected to the server.');
|
||||
}
|
||||
|
||||
final result = await unsealVault(connection, password);
|
||||
switch (result) {
|
||||
case UnsealResult.UNSEAL_RESULT_SUCCESS:
|
||||
unsealPasswordController.clear();
|
||||
await refreshVaultState();
|
||||
break;
|
||||
case UnsealResult.UNSEAL_RESULT_INVALID_KEY:
|
||||
errorText.value = 'Incorrect password.';
|
||||
break;
|
||||
case UnsealResult.UNSEAL_RESULT_UNBOOTSTRAPPED:
|
||||
errorText.value =
|
||||
'The vault is not bootstrapped yet. Refreshing vault state.';
|
||||
await refreshVaultState();
|
||||
break;
|
||||
case UnsealResult.UNSEAL_RESULT_UNSPECIFIED:
|
||||
errorText.value = 'Failed to unseal the vault.';
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
errorText.value = _formatVaultError(error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final body = switch (vaultState) {
|
||||
AsyncLoading() => const Center(child: CircularProgressIndicator()),
|
||||
AsyncError(:final error) => _VaultCard(
|
||||
title: 'Vault unavailable',
|
||||
subtitle: _formatVaultError(error),
|
||||
child: FilledButton(
|
||||
onPressed: () => ref.invalidate(vaultStateProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
AsyncData(:final value) => switch (value) {
|
||||
VaultState.VAULT_STATE_UNBOOTSTRAPPED => _VaultCard(
|
||||
title: 'Create vault password',
|
||||
subtitle:
|
||||
'Choose the password that will be required to unseal this vault.',
|
||||
child: _PasswordForm(
|
||||
errorText: errorText.value,
|
||||
isSubmitting: isSubmitting.value,
|
||||
submitLabel: 'Bootstrap vault',
|
||||
onSubmit: submitBootstrap,
|
||||
fields: [
|
||||
_PasswordFieldConfig(
|
||||
controller: bootstrapPasswordController,
|
||||
label: 'Password',
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
_PasswordFieldConfig(
|
||||
controller: bootstrapConfirmController,
|
||||
label: 'Confirm password',
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => submitBootstrap(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
VaultState.VAULT_STATE_SEALED => _VaultCard(
|
||||
title: 'Unseal vault',
|
||||
subtitle: 'Enter the vault password to continue.',
|
||||
child: _PasswordForm(
|
||||
errorText: errorText.value,
|
||||
isSubmitting: isSubmitting.value,
|
||||
submitLabel: 'Unseal vault',
|
||||
onSubmit: submitUnseal,
|
||||
fields: [
|
||||
_PasswordFieldConfig(
|
||||
controller: unsealPasswordController,
|
||||
label: 'Password',
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => submitUnseal(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
VaultState.VAULT_STATE_UNSEALED => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
VaultState.VAULT_STATE_ERROR => _VaultCard(
|
||||
title: 'Vault state unavailable',
|
||||
subtitle: 'Unable to determine the current vault state.',
|
||||
child: FilledButton(
|
||||
onPressed: () => ref.invalidate(vaultStateProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
VaultState.VAULT_STATE_UNSPECIFIED => _VaultCard(
|
||||
title: 'Vault state unavailable',
|
||||
subtitle: 'Unable to determine the current vault state.',
|
||||
child: FilledButton(
|
||||
onPressed: () => ref.invalidate(vaultStateProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
null => _VaultCard(
|
||||
title: 'Vault state unavailable',
|
||||
subtitle: 'Unable to determine the current vault state.',
|
||||
child: FilledButton(
|
||||
onPressed: () => ref.invalidate(vaultStateProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
_ => _VaultCard(
|
||||
title: 'Vault state unavailable',
|
||||
subtitle: 'Unable to determine the current vault state.',
|
||||
child: FilledButton(
|
||||
onPressed: () => ref.invalidate(vaultStateProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Vault Setup')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 3.h),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VaultCard extends StatelessWidget {
|
||||
const _VaultCard({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 12),
|
||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 24),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PasswordForm extends StatelessWidget {
|
||||
const _PasswordForm({
|
||||
required this.fields,
|
||||
required this.errorText,
|
||||
required this.isSubmitting,
|
||||
required this.submitLabel,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
final List<_PasswordFieldConfig> fields;
|
||||
final String? errorText;
|
||||
final bool isSubmitting;
|
||||
final String submitLabel;
|
||||
final Future<void> Function() onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final field in fields) ...[
|
||||
TextField(
|
||||
controller: field.controller,
|
||||
obscureText: true,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
textInputAction: field.textInputAction,
|
||||
onSubmitted: field.onSubmitted,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: field.label,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (errorText != null) ...[
|
||||
Text(
|
||||
errorText!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton(
|
||||
onPressed: isSubmitting ? null : () => onSubmit(),
|
||||
child: Text(isSubmitting ? 'Working...' : submitLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PasswordFieldConfig {
|
||||
const _PasswordFieldConfig({
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.textInputAction,
|
||||
this.onSubmitted,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final TextInputAction textInputAction;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
}
|
||||
|
||||
class _WarningPopup extends StatelessWidget {
|
||||
const _WarningPopup({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.confirmLabel,
|
||||
required this.onCancel,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
final String confirmLabel;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
Text(body, style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(onPressed: onCancel, child: const Text('Cancel')),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(onPressed: onConfirm, child: Text(confirmLabel)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatVaultError(Object error) {
|
||||
final message = error.toString();
|
||||
|
||||
if (message.contains('GrpcError')) {
|
||||
return 'The server rejected the vault request. Check the password and try again.';
|
||||
}
|
||||
|
||||
return message.replaceFirst('Exception: ', '');
|
||||
}
|
||||
Reference in New Issue
Block a user