Files
tasq/lib/routing/app_router.dart
T
redz1029 3cb980b629 QA hardening: security RLS fixes, Flutter 3.47.5 upgrade, UI/validation fixes
Security — enforce write authorization server-side (was UI/RPC-only):
- it_service_requests RLS: block cross-office read/edit + self-approve (QA-015)
- pass_slips RLS: owner can complete but not self-approve (QA-046)
- swap_requests RLS: scope select/update to participants + admin (QA-047)
- storage: tighten it_service_attachments + task_attachments write/delete (QA-027)
- admin_user_management edge function: allow programmers to manage users (QA-016)

Fixes:
- workforce generator "uncovered shifts" false alarms (QA-043/044)
- network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048)
- de-flake time-of-day-dependent dashboard metrics test (QA-045)

Toolchain:
- upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0,
  flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002)

analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/.

Note: also carries the in-progress Brick model cleanup already present in the
working tree. QA-001 (AI keys public in the build) is deferred by owner decision.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 11:52:16 +08:00

377 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import '../providers/profile_provider.dart';
import '../providers/supabase_provider.dart';
import '../utils/lock_enforcer.dart';
import '../screens/auth/login_screen.dart';
import '../screens/auth/signup_screen.dart';
import '../screens/admin/offices_screen.dart';
import '../screens/admin/user_management_screen.dart';
import '../screens/admin/geofence_test_screen.dart';
import '../screens/admin/app_update_screen.dart';
import '../screens/dashboard/dashboard_screen.dart';
import '../screens/notifications/notifications_screen.dart';
import '../screens/profile/profile_screen.dart';
import '../screens/shared/mobile_verification_screen.dart';
import '../screens/announcements/announcements_screen.dart';
import '../screens/shared/permissions_screen.dart';
import '../screens/reports/reports_screen.dart';
import '../screens/tasks/task_detail_screen.dart';
import '../screens/tasks/tasks_list_screen.dart';
import '../screens/tickets/ticket_detail_screen.dart';
import '../screens/tickets/tickets_list_screen.dart';
import '../screens/workforce/workforce_screen.dart';
import '../screens/attendance/attendance_screen.dart';
import '../screens/whereabouts/whereabouts_screen.dart';
import '../widgets/app_shell.dart';
import '../screens/teams/teams_screen.dart';
import '../screens/it_service_requests/it_service_requests_list_screen.dart';
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
import '../screens/network_map/network_map_overview_screen.dart';
import '../screens/network_map/network_map_site_screen.dart';
import '../screens/network_map/network_map_device_screen.dart';
import '../screens/network_map/network_map_device_edit_screen.dart';
import '../screens/network_map/network_map_import_screen.dart';
import '../screens/network_map/network_map_import_review_screen.dart';
import '../screens/network_map/network_map_vlan_screen.dart';
import '../theme/m3_motion.dart';
import '../utils/navigation.dart';
Page<dynamic> Function(BuildContext, GoRouterState) _page(Widget child) =>
(context, state) => M3SharedAxisPage(key: state.pageKey, child: child);
String _defaultRouteForRole(String? role) {
switch (role) {
case 'it_staff':
return '/tasks';
case 'standard':
return '/tickets';
default:
return '/dashboard';
}
}
final appRouterProvider = Provider<GoRouter>((ref) {
final notifier = RouterNotifier(ref);
ref.onDispose(notifier.dispose);
return GoRouter(
navigatorKey: globalNavigatorKey,
initialLocation: '/dashboard',
refreshListenable: notifier,
redirect: (context, state) {
final authState = ref.read(authStateChangesProvider);
dynamic session;
if (authState is AsyncData) {
final state = authState.value;
session = state?.session;
} else {
session = ref.read(sessionProvider);
}
final isAuthRoute =
state.fullPath == '/login' || state.fullPath == '/signup';
final isSignedIn = session != null;
final profileAsync = ref.read(currentProfileProvider);
final isAdminRoute = state.matchedLocation.startsWith('/settings');
final role = profileAsync is AsyncData
? (profileAsync.value)?.role
: null;
final isAdmin = role == 'admin' || role == 'programmer';
final isReportsRoute = state.matchedLocation == '/reports';
final hasReportsAccess =
role == 'admin' ||
role == 'programmer' ||
role == 'dispatcher' ||
role == 'it_staff';
final isNetworkMapRoute = state.matchedLocation.startsWith('/network-map');
final hasNetworkMapAccess =
role == 'admin' ||
role == 'it_staff' ||
role == 'dispatcher' ||
role == 'programmer';
if (!isSignedIn && !isAuthRoute) {
return '/login';
}
if (isSignedIn && isAuthRoute) {
// If role already loaded, redirect directly and clear any pending flag
if (role != null) notifier._needsRoleRedirect = false;
return _defaultRouteForRole(role);
}
// Deferred post-login redirect: profile loaded after the initial redirect
// (which fell back to /dashboard because role was null). Consumed once
// per sign-in; only redirects when the user is still on /dashboard.
if (isSignedIn && notifier._needsRoleRedirect && role != null) {
notifier._needsRoleRedirect = false;
if (state.matchedLocation == '/dashboard') {
return _defaultRouteForRole(role);
}
}
// Role unknown while the profile loads: hold the deep link instead of
// bouncing it. RouterNotifier re-runs this once the profile arrives.
if (isSignedIn && profileAsync.isLoading && role == null) {
return null;
}
if (isAdminRoute && !isAdmin) {
return '/tickets';
}
if (isReportsRoute && !hasReportsAccess) {
return '/tickets';
}
if (isNetworkMapRoute && !hasNetworkMapAccess) {
return '/tickets';
}
// Attendance & Whereabouts: not accessible to standard users
final isStandardOnly = role == 'standard';
final isAttendanceRoute =
state.matchedLocation == '/attendance' ||
state.matchedLocation == '/whereabouts';
if (isAttendanceRoute && isStandardOnly) {
return '/dashboard';
}
return null;
},
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
GoRoute(
path: '/signup',
builder: (context, state) => const SignUpScreen(),
),
GoRoute(
path: '/verify/:sessionId',
builder: (context, state) => MobileVerificationScreen(
sessionId: state.pathParameters['sessionId'] ?? '',
),
),
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
routes: [
GoRoute(
path: '/settings/teams',
pageBuilder: _page(const TeamsScreen()),
),
GoRoute(
path: '/settings/app-update',
pageBuilder: _page(const AppUpdateScreen()),
),
GoRoute(
path: '/dashboard',
pageBuilder: _page(const DashboardScreen()),
),
GoRoute(
path: '/tickets',
pageBuilder: _page(const TicketsListScreen()),
routes: [
GoRoute(
path: ':id',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: TicketDetailScreen(
ticketId: state.pathParameters['id'] ?? '',
),
),
),
],
),
GoRoute(
path: '/tasks',
pageBuilder: _page(const TasksListScreen()),
routes: [
GoRoute(
path: ':id',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: TaskDetailScreen(
taskId: state.pathParameters['id'] ?? '',
),
),
),
],
),
GoRoute(
path: '/it-service-requests',
pageBuilder: _page(const ItServiceRequestsListScreen()),
routes: [
GoRoute(
path: ':id',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: ItServiceRequestDetailScreen(
requestId: state.pathParameters['id'] ?? '',
),
),
),
],
),
GoRoute(
path: '/announcements',
pageBuilder: _page(const AnnouncementsScreen()),
),
GoRoute(
path: '/workforce',
pageBuilder: _page(const WorkforceScreen()),
),
GoRoute(
path: '/attendance',
pageBuilder: _page(const AttendanceScreen()),
),
GoRoute(
path: '/whereabouts',
pageBuilder: _page(const WhereaboutsScreen()),
),
GoRoute(path: '/reports', pageBuilder: _page(const ReportsScreen())),
GoRoute(
path: '/settings/users',
pageBuilder: _page(const UserManagementScreen()),
),
GoRoute(
path: '/settings/offices',
pageBuilder: _page(const OfficesScreen()),
),
GoRoute(
path: '/settings/geofence-test',
pageBuilder: _page(const GeofenceTestScreen()),
),
GoRoute(
path: '/settings/permissions',
pageBuilder: _page(const PermissionsScreen()),
),
GoRoute(
path: '/network-map',
pageBuilder: _page(const NetworkMapOverviewScreen()),
routes: [
GoRoute(
path: 'import',
pageBuilder: _page(const NetworkMapImportScreen()),
routes: [
GoRoute(
path: ':importId/review',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: NetworkMapImportReviewScreen(
importId: state.pathParameters['importId'] ?? '',
),
),
),
],
),
GoRoute(
path: 'vlans',
pageBuilder: _page(const NetworkMapVlanScreen()),
),
GoRoute(
path: 'site/:siteId',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: NetworkMapSiteScreen(
siteId: state.pathParameters['siteId'] ?? '',
),
),
routes: [
GoRoute(
path: 'device/new',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: NetworkMapDeviceEditScreen(
initialSiteId: state.pathParameters['siteId'],
),
),
),
],
),
GoRoute(
path: 'device/:deviceId',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: NetworkMapDeviceScreen(
deviceId: state.pathParameters['deviceId'] ?? '',
),
),
routes: [
GoRoute(
path: 'edit',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: NetworkMapDeviceEditScreen(
deviceId: state.pathParameters['deviceId'],
),
),
),
],
),
],
),
GoRoute(
path: '/notifications',
pageBuilder: _page(const NotificationsScreen()),
),
GoRoute(path: '/profile', pageBuilder: _page(const ProfileScreen())),
],
),
],
);
});
class RouterNotifier extends ChangeNotifier {
RouterNotifier(this.ref) {
_authSub = ref.listen(authStateChangesProvider, (previous, next) {
// Only enforce lock on successful sign-in events, not on every auth state change
if (next is AsyncData) {
final authState = next.value;
final session = authState?.session;
// Only check for bans when we have a session and the previous state didn't
final previousSession = previous is AsyncData
? previous.value?.session
: null;
if (session != null && previousSession == null) {
// User just signed in; enforce lock check and flag for role-based redirect
_enforceLockAsync();
_needsRoleRedirect = true;
}
}
notifyListeners();
});
_profileSub = ref.listen(currentProfileProvider, (previous, next) {
notifyListeners();
});
}
final Ref ref;
late final ProviderSubscription _authSub;
late final ProviderSubscription _profileSub;
bool _lockEnforcementInProgress = false;
/// Set on new sign-in. Consumed by the redirect function to send the user
/// to their role-appropriate landing page once the profile has loaded.
bool _needsRoleRedirect = false;
/// Safely enforce lock in the background, preventing concurrent calls
void _enforceLockAsync() {
// Prevent concurrent enforcement calls
if (_lockEnforcementInProgress) return;
_lockEnforcementInProgress = true;
// Use Future.microtask to defer execution and avoid blocking
Future.microtask(() async {
try {
await enforceLockForCurrentUser(ref.read(supabaseClientProvider));
} catch (e) {
debugPrint('RouterNotifier: lock enforcement error: $e');
} finally {
_lockEnforcementInProgress = false;
}
});
}
@override
void dispose() {
_authSub.close();
_profileSub.close();
super.dispose();
}
}