Build staff management, store details editing, and a forced PIN change
Wires the last two dead buttons in Settings and closes the loop on the credential work: hashed PINs are only worth having if a shop can actually change them. Users & roles (Manage) - Add, rename, re-role and remove staff. Admin-only at the door, because anyone who can edit staff can make themselves an admin. - PIN and confirmation are both required and must match. There is no email to reset a PIN with, so a typo nobody can verify locks the account out until an admin intervenes. - Editing someone leaves their PIN alone unless a new one is typed. An admin setting another person's PIN counts as a reset and re-arms must-change. - Removal is a deactivation with a confirmation that explains why: bills already rung keep the cashier's name, so shift reports stay correct. - Anyone still on a shipped PIN is flagged in the list and in Settings. Store details (Edit) - Name, address, GSTIN and phone now editable and persisted. GSTIN is format and state-code validated; it prints on every invoice as a legal requirement, so a typo is a compliance problem across hundreds of bills. - Admin-only: changing the GSTIN changes what every future invoice claims about who collected the tax. Forced PIN change - Shown once after sign-in while must-change is set, and not dismissable. The seeded PINs are in the source of the build, so a terminal still running one is effectively unprotected. Fixed while testing: the role dropdown laid its items out at natural width and "Manager — Sales, inventory and reports" overflowed the dialog by 222px. Now isExpanded with the description spelled out below, where it is readable. Tests: 160 -> 168. Covers both role guards, the mismatched and too-short PIN paths, the default-PIN flag, and GSTIN and seller-name validation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
538
lib/presentation/modules/widgets/staff_dialogs.dart
Normal file
538
lib/presentation/modules/widgets/staff_dialogs.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/primary_button.dart';
|
||||
import '../../../data/local/staff_dao.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
|
||||
/// Manage who can sign in at this till.
|
||||
Future<void> showStaffDialog(BuildContext context) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const _StaffDialog(),
|
||||
);
|
||||
|
||||
class _StaffDialog extends ConsumerWidget {
|
||||
const _StaffDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final me = ref.watch(currentUserProvider);
|
||||
final storeAsync = ref.watch(storeAccountProvider);
|
||||
|
||||
// Only an admin can change who works here, or what they may do.
|
||||
if (me?.role != StaffRole.admin) {
|
||||
return AlertDialog(
|
||||
title: const Text('Users & roles'),
|
||||
content: const Text(
|
||||
'Only an admin can add or change staff accounts. Ask a manager to '
|
||||
'sign in first.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Users & roles'),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: storeAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Text('Could not load staff: $e'),
|
||||
data: (store) => SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final user in store.staff)
|
||||
_StaffRow(user: user, isMe: user.id == me?.id),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openEditor(context, ref, null),
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded, size: 17),
|
||||
label: const Text('Add staff member'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StaffRow extends ConsumerWidget {
|
||||
const _StaffRow({required this.user, required this.isMe});
|
||||
|
||||
final StaffUser user;
|
||||
final bool isMe;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
user.role.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (user.mustChangePin)
|
||||
const Tooltip(
|
||||
message: 'Still on the PIN this terminal was set up with',
|
||||
child: Icon(Icons.warning_amber_rounded,
|
||||
size: 17, color: AppColors.warning,),
|
||||
),
|
||||
if (isMe)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||
child: Text(
|
||||
'Signed in',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.success),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit_outlined, size: 17),
|
||||
onPressed: () => _openEditor(context, ref, user),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Remove',
|
||||
icon: const Icon(Icons.person_off_outlined, size: 17),
|
||||
color: AppColors.danger,
|
||||
onPressed: () => _confirmDeactivate(context, ref, user),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDeactivate(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
StaffUser user,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text('Remove ${user.name}?'),
|
||||
content: const Text(
|
||||
'They will no longer be able to sign in. Bills they have already rung '
|
||||
'keep their name, so shift reports stay correct.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.danger),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
try {
|
||||
await ref.read(localStoreProvider).staff.deactivate(user.id);
|
||||
await ref.read(authControllerProvider.notifier).refreshStore();
|
||||
} on StaffException catch (e) {
|
||||
if (context.mounted) _showError(context, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openEditor(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
StaffUser? existing,
|
||||
) =>
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _StaffEditor(existing: existing),
|
||||
);
|
||||
|
||||
void _showError(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
backgroundColor: AppColors.danger,
|
||||
content: Text(message),
|
||||
),);
|
||||
}
|
||||
|
||||
/// Add a staff member, or change one's name, role or PIN.
|
||||
class _StaffEditor extends ConsumerStatefulWidget {
|
||||
const _StaffEditor({this.existing});
|
||||
|
||||
final StaffUser? existing;
|
||||
|
||||
@override
|
||||
ConsumerState<_StaffEditor> createState() => _StaffEditorState();
|
||||
}
|
||||
|
||||
class _StaffEditorState extends ConsumerState<_StaffEditor> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _name;
|
||||
final _pin = TextEditingController();
|
||||
final _confirmPin = TextEditingController();
|
||||
|
||||
late StaffRole _role;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _isNew => widget.existing == null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_name = TextEditingController(text: widget.existing?.name ?? '');
|
||||
_role = widget.existing?.role ?? StaffRole.cashier;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_pin.dispose();
|
||||
_confirmPin.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final staff = ref.read(localStoreProvider).staff;
|
||||
|
||||
try {
|
||||
if (_isNew) {
|
||||
await staff.create(
|
||||
name: _name.text,
|
||||
role: _role,
|
||||
pin: _pin.text,
|
||||
);
|
||||
} else {
|
||||
await staff.updateDetails(
|
||||
id: widget.existing!.id,
|
||||
name: _name.text,
|
||||
role: _role,
|
||||
);
|
||||
// Blank means "leave it alone" — an admin editing a role should not be
|
||||
// forced to know or reset someone's PIN.
|
||||
if (_pin.text.isNotEmpty) {
|
||||
await staff.setPin(
|
||||
widget.existing!.id,
|
||||
_pin.text,
|
||||
// An admin setting someone else's PIN is a reset, so the person is
|
||||
// asked to choose their own at next sign-in.
|
||||
mustChangePin: widget.existing!.id !=
|
||||
ref.read(currentUserProvider)?.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await ref.read(authControllerProvider.notifier).refreshStore();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} on StaffException catch (e) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = e.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(_isNew ? 'Add staff member' : 'Edit ${widget.existing!.name}'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _name,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty)
|
||||
? 'A staff member needs a name'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
DropdownButtonFormField<StaffRole>(
|
||||
initialValue: _role,
|
||||
// Without this the item is laid out at its natural width and
|
||||
// "Manager — Sales, inventory and reports" runs 222px past the
|
||||
// edge of the dialog.
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Role'),
|
||||
items: [
|
||||
for (final role in StaffRole.values)
|
||||
DropdownMenuItem(
|
||||
value: role,
|
||||
child: Text(
|
||||
'${role.label} — ${role.description}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _role = v ?? _role),
|
||||
),
|
||||
// Spelled out below rather than squeezed into the dropdown, so
|
||||
// the permissions being granted are actually readable.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||
child: Text(
|
||||
_role.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
TextFormField(
|
||||
controller: _pin,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: _isNew ? 'PIN' : 'New PIN',
|
||||
helperText: _isNew
|
||||
? 'At least four digits, and not guessable across a '
|
||||
'counter'
|
||||
: 'Leave blank to keep the current PIN',
|
||||
),
|
||||
validator: (v) {
|
||||
final text = v ?? '';
|
||||
if (_isNew && text.isEmpty) return 'A PIN is required';
|
||||
if (text.isNotEmpty && text.length < 4) {
|
||||
return 'At least four digits';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
TextFormField(
|
||||
controller: _confirmPin,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(labelText: 'Confirm PIN'),
|
||||
validator: (v) {
|
||||
// A mistyped PIN nobody can verify locks the account out —
|
||||
// there is no email to reset it with.
|
||||
if (_pin.text.isEmpty) return null;
|
||||
return v != _pin.text ? 'The two PINs do not match' : null;
|
||||
},
|
||||
),
|
||||
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
_error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.danger,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: _isNew ? 'Add' : 'Save',
|
||||
expanded: false,
|
||||
busy: _saving,
|
||||
onPressed: _save,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces someone off a PIN they did not choose.
|
||||
///
|
||||
/// Shown after sign-in while [StaffUser.mustChangePin] is set. Not dismissable:
|
||||
/// the seeded PINs are in the source of an open-source build, so a shop still
|
||||
/// running one is effectively unprotected.
|
||||
Future<void> showForcedPinChange(BuildContext context) => showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _ForcedPinChange(),
|
||||
);
|
||||
|
||||
class _ForcedPinChange extends ConsumerStatefulWidget {
|
||||
const _ForcedPinChange();
|
||||
|
||||
@override
|
||||
ConsumerState<_ForcedPinChange> createState() => _ForcedPinChangeState();
|
||||
}
|
||||
|
||||
class _ForcedPinChangeState extends ConsumerState<_ForcedPinChange> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _pin = TextEditingController();
|
||||
final _confirm = TextEditingController();
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pin.dispose();
|
||||
_confirm.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final me = ref.read(currentUserProvider);
|
||||
if (me == null) return;
|
||||
|
||||
try {
|
||||
await ref.read(localStoreProvider).staff.setPin(me.id, _pin.text);
|
||||
await ref.read(authControllerProvider.notifier).refreshStore();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} on StaffException catch (e) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = e.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final me = ref.watch(currentUserProvider);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: const Text('Choose your PIN'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'${me?.name ?? 'This account'} is still using the PIN this '
|
||||
'terminal was set up with. Those are the same on every new '
|
||||
'install, so please pick your own before ringing a sale.',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
TextFormField(
|
||||
controller: _pin,
|
||||
autofocus: true,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
decoration: const InputDecoration(labelText: 'New PIN'),
|
||||
validator: (v) => (v == null || v.length < 4)
|
||||
? 'At least four digits'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
|
||||
TextFormField(
|
||||
controller: _confirm,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(labelText: 'Confirm PIN'),
|
||||
validator: (v) =>
|
||||
v != _pin.text ? 'The two PINs do not match' : null,
|
||||
),
|
||||
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
_error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.danger,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
PrimaryButton(
|
||||
label: 'Set PIN',
|
||||
expanded: false,
|
||||
busy: _saving,
|
||||
onPressed: _save,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user