Take the terminal's role from the back office, not from which tab was clicked

The role split was right; only its source was wrong. Signing in matched what
was typed against two constants compiled into the app — admin@nearle.in and
cashier@nearle.in — so which shell a person got was a property of the *build*.
A shop could not add a third person, revoke either of the two it had, or stop
anyone with the APK reading both passwords out of it.

TerminalLogin survives unchanged in shape, because the shape was the good part:
one flag the shell reads, a session that decides it, and a cashier sign-out that
takes the catalogue with it while a supervisor's leaves it behind. Every
consumer — visibleModulesProvider, resolvedModuleProvider, the sidebar, the page
header, the sign-out dialog — is untouched. What changed is that the enum is now
only constructible from a session the back office signed, so there is no path
left where the terminal grants itself a permission the server did not send.

It reads `can_manage_staff` rather than the role name or id. app_roles holds six
rows for four distinct roles, a great many accounts carry a roleid that is not
in the table at all, and the name comes back blank for most of them. Matching on
either would mean shipping a copy of the role table in the app and keeping the
two in step for ever. One boolean, decided server-side, cannot drift. It
defaults to false, which matters on the restore path: a session saved by a build
that predates the field comes back as a cashier, never silently as an admin.

This also restores the sign-in layer itself — pos_auth_api, pos_session,
session_store, the staff import and the bearer token — which an earlier commit
removed wholesale from a stale checkout. Its parent was the commit that added
them, so the deletion was a bad merge rather than a decision; the terminal has
been running on the two constants since.

The login screen loses its role tabs and its credential prefill. You do not
choose what you are on the way in.

The opener is now matched on the back office user id rather than on the first
account with a matching role, so the first bill of a shift is attributed to
whoever actually signed in.

Tests: the smoke suite pinned only the supervisor shell, and it was passing for
the wrong reason — the fake session omitted can_manage_staff, and the sidebar it
asserted on was there because the role was hardcoded. Both halves are pinned now
and the fake is parameterised. widget_test.dart was the stock Flutter counter
template, restored by the same bad merge, testing a MyApp that has never existed
in this repo.

292 tests pass; analyzer reports no errors and no warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-07 11:06:00 +05:30
parent eebd10da6d
commit 829e5a8188
16 changed files with 994 additions and 325 deletions

View File

@@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -10,7 +8,6 @@ import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/validators.dart';
import '../../../core/widgets/brand_mark.dart';
import '../../../core/widgets/primary_button.dart';
import '../providers/auth_controller.dart';
@@ -32,27 +29,15 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
/// Which of the two default accounts the tabs are pointing at. Only a
/// convenience for filling the fields — [AuthController.signIn] decides the
/// role from the email that is actually submitted, so typing a different
/// address over the top still signs in as that account.
TerminalLogin _login = TerminalLogin.admin;
late final _email = TextEditingController(text: _login.email);
late final _password = TextEditingController(text: _login.password);
// Both fields start empty. There is nothing to prefill: a person signs in
// with their own back-office account, and which shell they get is a property
// of that account rather than a tab they picked before typing.
final _email = TextEditingController();
final _password = TextEditingController();
bool _obscure = true;
bool _rememberTerminal = true;
void _selectLogin(TerminalLogin login) {
setState(() {
_login = login;
_email.text = login.email;
_password.text = login.password;
});
ref.read(authControllerProvider.notifier).clearError();
}
@override
void dispose() {
_email.dispose();
@@ -124,10 +109,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
password: _password,
obscure: _obscure,
rememberTerminal: _rememberTerminal,
login: _login,
busy: busy,
failure: auth is AuthFailure ? auth.message : null,
onSelectLogin: _selectLogin,
onToggleObscure: () =>
setState(() => _obscure = !_obscure),
onToggleRemember: (v) =>
@@ -203,10 +186,8 @@ class _Card extends StatelessWidget {
required this.password,
required this.obscure,
required this.rememberTerminal,
required this.login,
required this.busy,
required this.failure,
required this.onSelectLogin,
required this.onToggleObscure,
required this.onToggleRemember,
required this.onSubmit,
@@ -217,10 +198,8 @@ class _Card extends StatelessWidget {
final TextEditingController password;
final bool obscure;
final bool rememberTerminal;
final TerminalLogin login;
final bool busy;
final String? failure;
final ValueChanged<TerminalLogin> onSelectLogin;
final VoidCallback onToggleObscure;
final ValueChanged<bool?> onToggleRemember;
final VoidCallback onSubmit;
@@ -268,22 +247,6 @@ class _Card extends StatelessWidget {
),
const SizedBox(height: AppSpacing.xl),
_RoleSwitch(
selected: login,
enabled: !busy,
onSelect: onSelectLogin,
),
const SizedBox(height: AppSpacing.sm),
Text(
login.blurb,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textTertiary,
height: 1.45,
),
),
const SizedBox(height: AppSpacing.xl),
const _Label('Store email'),
TextFormField(
controller: email,
@@ -403,12 +366,6 @@ class _Card extends StatelessWidget {
busy: busy,
onPressed: onSubmit,
),
const SizedBox(height: AppSpacing.lg),
_DemoHint(
login: login,
onFill: busy ? null : () => onSelectLogin(login),
),
],
),
),
@@ -436,168 +393,3 @@ class _Label extends StatelessWidget {
);
}
}
/// Which of the two default accounts is being signed into.
///
/// The roles are not cosmetic — they decide whether the terminal opens the
/// full shell or the billing screen alone, and whether signing out leaves the
/// products behind — so the choice is made before the credentials rather than
/// inferred from them afterwards.
class _RoleSwitch extends StatelessWidget {
const _RoleSwitch({
required this.selected,
required this.enabled,
required this.onSelect,
});
final TerminalLogin selected;
final bool enabled;
final ValueChanged<TerminalLogin> onSelect;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
for (final login in TerminalLogin.values)
Expanded(
child: _RoleTab(
login: login,
selected: login == selected,
enabled: enabled,
onTap: () => onSelect(login),
),
),
],
),
);
}
}
class _RoleTab extends StatelessWidget {
const _RoleTab({
required this.login,
required this.selected,
required this.enabled,
required this.onTap,
});
final TerminalLogin login;
final bool selected;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final icon = login == TerminalLogin.admin
? Icons.admin_panel_settings_outlined
: Icons.point_of_sale_rounded;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: AppRadius.brXs,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.surface : Colors.transparent,
borderRadius: AppRadius.brXs,
border: Border.all(
color: selected ? AppColors.primaryBorder : Colors.transparent,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 17,
color: selected ? AppColors.primary : AppColors.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Flexible(
child: Text(
login.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color:
selected ? AppColors.primary : AppColors.textSecondary,
),
),
),
],
),
),
),
);
}
}
class _DemoHint extends StatelessWidget {
const _DemoHint({required this.login, this.onFill});
final TerminalLogin login;
final VoidCallback? onFill;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.primarySurface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.primaryBorder),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline_rounded,
size: 17, color: AppColors.primary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Default ${login.label.toLowerCase()} account',
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(height: 2),
SelectableText(
'${login.email} \u00b7 ${login.password}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
TextButton(
onPressed: onFill,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
),
child: const Text('Fill', style: TextStyle(fontSize: 12.5)),
),
],
),
);
}
}