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>
This commit is contained in:
2026-09-26 11:52:16 +08:00
parent e49b52949c
commit 3cb980b629
125 changed files with 3420 additions and 2853 deletions
+41 -16
View File
@@ -3,17 +3,19 @@ import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tasq/utils/app_time.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../models/office.dart';
import '../../models/office.model.dart';
import '../../widgets/office_picker.dart';
import '../../models/notification_item.dart';
import '../../models/profile.dart';
import '../../models/ticket.dart';
import '../../models/notification_item.model.dart';
import '../../models/profile.model.dart';
import '../../models/ticket.model.dart';
import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../providers/realtime_controller.dart';
import '../../providers/typing_provider.dart';
import '../../providers/user_offices_provider.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.dart';
import 'package:skeletonizer/skeletonizer.dart';
@@ -288,7 +290,10 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
TasQColumn<Ticket>(
header: 'Ticket ID',
technical: true,
cellBuilder: (context, ticket) => Text(ticket.id),
cellBuilder: (context, ticket) => Tooltip(
message: ticket.id,
child: Text(_shortId(ticket.id)),
),
),
TasQColumn<Ticket>(
header: 'Subject',
@@ -362,7 +367,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
const SizedBox(height: 2),
Text('Filed by: $assigned'),
const SizedBox(height: 4),
MonoText('ID ${ticket.id}'),
MonoText('ID ${_shortId(ticket.id)}'),
const SizedBox(height: 2),
Text(_formatTimestamp(ticket.createdAt)),
],
@@ -450,6 +455,17 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
return Consumer(
builder: (context, ref, _) {
final officesAsync = ref.watch(officesProvider);
final profile = ref.watch(currentProfileProvider).valueOrNull;
final isGlobal = const {
'admin',
'programmer',
'dispatcher',
'it_staff',
}.contains(profile?.role);
final myOfficeIds = {
for (final a in ref.watch(userOfficesProvider).valueOrNull ?? [])
if (a.userId == profile?.id) a.officeId,
};
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -468,8 +484,18 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
),
const SizedBox(height: 12),
officesAsync.when(
data: (offices) {
if (offices.isEmpty) return const Text('No offices assigned.');
data: (allOffices) {
// ticketsProvider only shows non-global users their own
// offices' tickets, so only offer those offices here —
// otherwise the new ticket vanishes from the filer's list.
final offices = isGlobal
? allOffices
: allOffices
.where((o) => myOfficeIds.contains(o.id))
.toList();
if (offices.isEmpty) {
return const Text('No offices assigned.');
}
final officesSorted = List<Office>.from(offices)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return OfficeSelectorField(
@@ -761,6 +787,11 @@ class _StatusSummaryCard extends StatelessWidget {
}
}
/// First 8 chars of the UUID, like a short git hash; the full id is in the
/// tooltip. Tickets have no human-friendly number.
String _shortId(String id) =>
id.length > 8 ? id.substring(0, 8).toUpperCase() : id;
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
if (userId == null || userId.isEmpty) {
return 'Unassigned';
@@ -772,14 +803,8 @@ String _assignedAgent(Map<String, Profile> profileById, String? userId) {
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
}
String _formatTimestamp(DateTime value) {
final year = value.year.toString().padLeft(4, '0');
final month = value.month.toString().padLeft(2, '0');
final day = value.day.toString().padLeft(2, '0');
final hour = value.hour.toString().padLeft(2, '0');
final minute = value.minute.toString().padLeft(2, '0');
return '$year-$month-$day $hour:$minute';
}
String _formatTimestamp(DateTime value) =>
DateFormat('yyyy-MM-dd HH:mm').format(value);
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});