diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..ab32aad1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,6 @@ +{ + "enabledMcpjsonServers": [ + "supabase" + ], + "enableAllProjectMcpServers": true +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..82214f0b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- **Run tests**: `flutter test` +- **Run static analysis**: `flutter analyze` (Must be clean before submission) +- **Format code**: `dart format lib/ test/` +- **Build Android APK**: `flutter build apk --release` +- **Build iOS**: `flutter build ios --release` +- **Build Windows**: `flutter build windows --release` + +## Development Workflow + +1. **Code**: Write feature with implementation in `lib/` and tests in `test/` +2. **Analyze**: `flutter analyze` (Must be clean) +3. **Test**: `flutter test` (Must pass) +4. **Verify**: Ensure UI matches the Hybrid M3/M2 guidelines + +## Architecture Overview + +**TasQ** is a Flutter task management application with Supabase backend. The architecture follows a clean separation: + +``` +State Management → Providers (Riverpod) → Repository/Service → Supabase + ↓ + Widgets (UI) +``` + +### Core Stack + +- **Framework**: Flutter with Material 3 (`useMaterial3: true`) +- **State Management**: Riverpod with `StreamProvider`, `StateNotifier`, and `FutureProvider` +- **Routing**: Go Router with auth-based redirects and ShellRoute for layout +- **Backend**: Supabase (PostgreSQL + Auth + Realtime) + +### UI/UX Design System (Hybrid M3/M2) + +Strictly follow this hybrid approach: +- **Base Theme**: `useMaterial3: true`. Use M3 for ColorScheme (`ColorScheme.fromSeed`), Typography, Navigation Bars, and Buttons. +- **Cards & Surfaces (The M2 Exception)**: + - Do **NOT** use flat M3 cards — cards are the one M2 visual exception in our hybrid system. Use M3 for tokens, M2 for card elevation. + - Card token rules (strict): + - `cardTheme.elevation`: 3 (allowed range 2–4) + - `cardTheme.shape.borderRadius`: 12–16 + - `card border`: 1px `outlineVariant` to readably separate content + - `shadowColor`: subtle black (light theme ~0.12, dark theme ~0.24) + - Implementation rules: + - Do **not** set `elevation: 0` on `Card` or other surfaced containers — rely on `CardTheme`. + - Keep `useMaterial3: true` for ColorScheme, Typography, Navigation and Buttons; only the card elevation/shadows follow M2. + - Prefer `Card` without local elevation overrides so the theme controls appearance across the app. + - Acceptance criteria: + - `lib/theme/app_theme.dart` sets `cardTheme.elevation` within 2–4 and borderRadius 12–16. + - No source file should set `elevation: 0` on `Card` (exceptions must be documented with a code comment). + - Add a widget test (`test/theme_overhaul_test.dart`) asserting card elevation and visual material elevation. + - Purpose: Provide clear, elevated surfaces for lists/boards while keeping M3 tokens for color/typography. +- **Typography**: Strict adherence to `TextTheme` variables (e.g., `titleLarge`, `bodyMedium`); **NO** hardcoded font sizes. + +### Data Fetching & Search (Critical) + +**Server-Side Pagination**: +- **ALWAYS** implement server-side pagination using Supabase `.range(start, end)`. +- Do **NOT** fetch all rows and filter locally. +- Standard page size: 50 items (Desktop), Infinite Scroll chunks (Mobile). + +**Full Text Search (FTS)**: +- Use Supabase `.textSearch()` or `.ilike()` on the server side. +- Search state must be managed in a Riverpod provider (e.g., `searchQueryProvider`) and passed to the repository stream. + +**Real-Time Data**: +- Use `StreamProvider` for live updates, but ensure streams respect current search/filter parameters. + +### Data Flow Pattern + +1. **Supabase Queries**: Server-side pagination and search via `.range()` and `.textSearch()` +2. **Providers** (`lib/providers/`) expose data via `StreamProvider` for real-time updates +3. **Providers** handle role-based data filtering (admin/dispatcher/it_staff have global access; standard users are filtered by office assignments) +4. **Widgets** consume providers using `ConsumerWidget` or `ConsumerStatefulWidget` +5. **Controllers** (`*Controller` classes) handle mutations (create/update/delete) + +### Debugging & Troubleshooting Protocol + +- **RLS vs. Logic**: + - If data returns empty or incomplete, **DO NOT** assume it is a Row Level Security (RLS) issue immediately. + - **Step 1**: Check the query logic, specifically `.range()` offsets and `.eq()` filters. + - **Step 2**: Verify the user role and search term binding. + - **Step 3**: Only check RLS if specific error codes (`401`, `403`, `PGRST`) are returned. + +### Key Patterns + +**Role-Based Access Control**: User roles (`admin`, `dispatcher`, `it_staff`, `standard`) determine data visibility and available UI sections. See `lib/routing/app_router.dart` for routing guards and `lib/providers/` for data filters. + +**TasQAdaptiveList**: + - **Mobile**: Tile-based list with infinite scroll listeners. + - **Desktop**: Data Table with paginated footer. + - **Input**: Requires a reactive data source (`Stream>`) that responds to pagination/search providers. + +**Responsive Design**: +- **Support**: Mobile (<600px), Tablet (600-1024px), Desktop (>1024px) +- **Implementation**: `LayoutBuilder` or `ResponsiveWrapper` +- **Mobile**: Single-column, Infinite Scroll, Bottom Navigation +- **Desktop**: Multi-column (Row/Grid), Pagination Controls, Sidebar Navigation + +**Hybrid M3/M2 Theme**: See `lib/theme/app_theme.dart`: +- Use Material 3 for ColorScheme, Typography, Navigation Bars +- Use Material 2 elevation (shadows) for Cards to create visual separation + +### Important Providers + +| Provider | Purpose | +|----------|---------| +| `authStateChangesProvider` | Auth state stream | +| `sessionProvider` | Current session | +| `supabaseClientProvider` | Supabase client instance | +| `currentProfileProvider` | Current user's profile with role | +| `ticketsProvider` | User's accessible tickets (filtered by office) | +| `tasksProvider` | User's accessible tasks | + +### Time Handling + +All timestamps use `Asia/Manila` timezone via the `timezone` package. Use `AppTime.parse()` for parsing and `AppTime.format()` for display. See `lib/utils/app_time.dart`. + +## UI Conventions + +- **Zero Overflow Policy**: Wrap all text in `Flexible`/`Expanded` within `Row`/`Flex`. Use `SingleChildScrollView` for tall layouts. +- **Responsive Breakpoints**: Mobile (<600px), Tablet (600-1024px), Desktop (>1024px) +- **Mono Text**: Technical data uses `MonoText` widget (defined in `lib/theme/app_typography.dart`) +- **Status Pills**: Use `StatusPill` widget for status display + +## Project Conventions + +- **Testing**: Mandatory for all widgets/providers/repositories. +- **Mocking**: Use `mockito` for Supabase client mocking. +- **Documentation**: DartDoc (`///`) for public APIs. Explain *Why*, not just *How*. +- **Environment**: `.env` file manages `SUPABASE_URL` and `SUPABASE_ANON_KEY`. +- **Audio**: Notification sounds via `audioplayers`. +- **Key Files**: + - `lib/app.dart`: App setup/theme. + - `lib/routing/app_router.dart`: Routing & Auth guards. + - `lib/providers/`: State management. + +## Testing Strategy + +- Tests located in `test/` directory +- Use Riverpod's `ProviderScope` with `overrideWith` for mocking +- Mock Supabase client using `SupabaseClient('http://localhost', 'test-key')` +- Layout tests use ` tester.pump()` with 16ms delay for animations + +## Project Structure + +``` +lib/ +├── app.dart # App entrypoint (theme/router) +├── main.dart # Widget initialization +├── models/ # Data models (fromMap/toJson) +├── providers/ # Riverpod providers & controllers +├── routing/ +│ └── app_router.dart # Go Router config with auth guards +├── screens/ # Screen widgets +│ ├── admin/ # Admin-only screens +│ ├── auth/ # Login/Signup +│ ├── dashboard/ # Dashboard +│ ├── notifications/ # Notification center +│ ├── tasks/ # Task management +│ ├── tickets/ # Ticket management +│ ├── teams/ # Team management +│ ├── workforce/ # Workforce management +│ └── shared/ # Shared components +├── theme/ # AppTheme, typography +├── utils/ # Utilities (AppTime) +└── widgets/ # Reusable widgets +``` + diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 0a531ebd..b07c771e 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -2,7 +2,7 @@ "servers": { "supabase": { "type": "http", - "url": "https://mcp.supabase.com/mcp?project_ref=wqjebgpbwrfzshaabprh" + "url": "https://mcp.supabase.com/mcp?project_ref=pwbxgsuskvqwwaejxutj" } }, "inputs": [] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..16e4b674 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,162 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- **Run tests**: `flutter test` +- **Run static analysis**: `flutter analyze` (Must be clean before submission) +- **Format code**: `dart format lib/ test/` +- **Build Android APK**: `flutter build apk --release` +- **Build iOS**: `flutter build ios --release` +- **Build Windows**: `flutter build windows --release` + +## Development Workflow + +1. **Code**: Write feature with implementation in `lib/` and tests in `test/` +2. **Analyze**: `flutter analyze` (Must be clean) +3. **Test**: `flutter test` (Must pass) +4. **Verify**: Ensure UI matches the Hybrid M3/M2 guidelines + +## Architecture Overview + +**TasQ** is a Flutter task management application with Supabase backend. The architecture follows a clean separation: + +``` +State Management → Providers (Riverpod) → Repository/Service → Supabase + ↓ + Widgets (UI) +``` + +### Core Stack + +- **Framework**: Flutter with Material 3 (`useMaterial3: true`) +- **State Management**: Riverpod with `StreamProvider`, `StateNotifier`, and `FutureProvider` +- **Routing**: Go Router with auth-based redirects and ShellRoute for layout +- **Backend**: Supabase (PostgreSQL + Auth + Realtime) + +### UI/UX Design System (Hybrid M3/M2) + +Strictly follow this hybrid approach: +- **Base Theme**: `useMaterial3: true`. Use M3 for ColorScheme (`ColorScheme.fromSeed`), Typography, Navigation Bars, and Buttons. +- **Cards & Surfaces (The M2 Exception)**: + - Do **NOT** use flat M3 cards. + - **Use Material 2 style elevation**: Cards must have visible shadows (`elevation: 2` to `4`) and distinct borders/colors to separate them from the background. + - Purpose: To create clear separation of content in lists and boards. +- **Typography**: Strict adherence to `TextTheme` variables (e.g., `titleLarge`, `bodyMedium`); **NO** hardcoded font sizes. + +### Data Fetching & Search (Critical) + +**Server-Side Pagination**: +- **ALWAYS** implement server-side pagination using Supabase `.range(start, end)`. +- Do **NOT** fetch all rows and filter locally. +- Standard page size: 50 items (Desktop), Infinite Scroll chunks (Mobile). + +**Full Text Search (FTS)**: +- Use Supabase `.textSearch()` or `.ilike()` on the server side. +- Search state must be managed in a Riverpod provider (e.g., `searchQueryProvider`) and passed to the repository stream. + +**Real-Time Data**: +- Use `StreamProvider` for live updates, but ensure streams respect current search/filter parameters. + +### Data Flow Pattern + +1. **Supabase Queries**: Server-side pagination and search via `.range()` and `.textSearch()` +2. **Providers** (`lib/providers/`) expose data via `StreamProvider` for real-time updates +3. **Providers** handle role-based data filtering (admin/dispatcher/it_staff have global access; standard users are filtered by office assignments) +4. **Widgets** consume providers using `ConsumerWidget` or `ConsumerStatefulWidget` +5. **Controllers** (`*Controller` classes) handle mutations (create/update/delete) + +### Debugging & Troubleshooting Protocol + +- **RLS vs. Logic**: + - If data returns empty or incomplete, **DO NOT** assume it is a Row Level Security (RLS) issue immediately. + - **Step 1**: Check the query logic, specifically `.range()` offsets and `.eq()` filters. + - **Step 2**: Verify the user role and search term binding. + - **Step 3**: Only check RLS if specific error codes (`401`, `403`, `PGRST`) are returned. + +### Key Patterns + +**Role-Based Access Control**: User roles (`admin`, `dispatcher`, `it_staff`, `standard`) determine data visibility and available UI sections. See `lib/routing/app_router.dart` for routing guards and `lib/providers/` for data filters. + +**TasQAdaptiveList**: + - **Mobile**: Tile-based list with infinite scroll listeners. + - **Desktop**: Data Table with paginated footer. + - **Input**: Requires a reactive data source (`Stream>`) that responds to pagination/search providers. + +**Responsive Design**: +- **Support**: Mobile (<600px), Tablet (600-1024px), Desktop (>1024px) +- **Implementation**: `LayoutBuilder` or `ResponsiveWrapper` +- **Mobile**: Single-column, Infinite Scroll, Bottom Navigation +- **Desktop**: Multi-column (Row/Grid), Pagination Controls, Sidebar Navigation + +**Hybrid M3/M2 Theme**: See `lib/theme/app_theme.dart`: +- Use Material 3 for ColorScheme, Typography, Navigation Bars +- Use Material 2 elevation (shadows) for Cards to create visual separation + +### Important Providers + +| Provider | Purpose | +|----------|---------| +| `authStateChangesProvider` | Auth state stream | +| `sessionProvider` | Current session | +| `supabaseClientProvider` | Supabase client instance | +| `currentProfileProvider` | Current user's profile with role | +| `ticketsProvider` | User's accessible tickets (filtered by office) | +| `tasksProvider` | User's accessible tasks | + +### Time Handling + +All timestamps use `Asia/Manila` timezone via the `timezone` package. Use `AppTime.parse()` for parsing and `AppTime.format()` for display. See `lib/utils/app_time.dart`. + +## UI Conventions + +- **Zero Overflow Policy**: Wrap all text in `Flexible`/`Expanded` within `Row`/`Flex`. Use `SingleChildScrollView` for tall layouts. +- **Responsive Breakpoints**: Mobile (<600px), Tablet (600-1024px), Desktop (>1024px) +- **Mono Text**: Technical data uses `MonoText` widget (defined in `lib/theme/app_typography.dart`) +- **Status Pills**: Use `StatusPill` widget for status display + +## Project Conventions + +- **Testing**: Mandatory for all widgets/providers/repositories. +- **Mocking**: Use `mockito` for Supabase client mocking. +- **Documentation**: DartDoc (`///`) for public APIs. Explain *Why*, not just *How*. +- **Environment**: `.env` file manages `SUPABASE_URL` and `SUPABASE_ANON_KEY`. +- **Audio**: Notification sounds via `audioplayers`. +- **Key Files**: + - `lib/app.dart`: App setup/theme. + - `lib/routing/app_router.dart`: Routing & Auth guards. + - `lib/providers/`: State management. + +## Testing Strategy + +- Tests located in `test/` directory +- Use Riverpod's `ProviderScope` with `overrideWith` for mocking +- Mock Supabase client using `SupabaseClient('http://localhost', 'test-key')` +- Layout tests use ` tester.pump()` with 16ms delay for animations + +## Project Structure + +``` +lib/ +├── app.dart # App entrypoint (theme/router) +├── main.dart # Widget initialization +├── models/ # Data models (fromMap/toJson) +├── providers/ # Riverpod providers & controllers +├── routing/ +│ └── app_router.dart # Go Router config with auth guards +├── screens/ # Screen widgets +│ ├── admin/ # Admin-only screens +│ ├── auth/ # Login/Signup +│ ├── dashboard/ # Dashboard +│ ├── notifications/ # Notification center +│ ├── tasks/ # Task management +│ ├── tickets/ # Ticket management +│ ├── teams/ # Team management +│ ├── workforce/ # Workforce management +│ └── shared/ # Shared components +├── theme/ # AppTheme, typography +├── utils/ # Utilities (AppTime) +└── widgets/ # Reusable widgets +``` + diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..16e4b674 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,162 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- **Run tests**: `flutter test` +- **Run static analysis**: `flutter analyze` (Must be clean before submission) +- **Format code**: `dart format lib/ test/` +- **Build Android APK**: `flutter build apk --release` +- **Build iOS**: `flutter build ios --release` +- **Build Windows**: `flutter build windows --release` + +## Development Workflow + +1. **Code**: Write feature with implementation in `lib/` and tests in `test/` +2. **Analyze**: `flutter analyze` (Must be clean) +3. **Test**: `flutter test` (Must pass) +4. **Verify**: Ensure UI matches the Hybrid M3/M2 guidelines + +## Architecture Overview + +**TasQ** is a Flutter task management application with Supabase backend. The architecture follows a clean separation: + +``` +State Management → Providers (Riverpod) → Repository/Service → Supabase + ↓ + Widgets (UI) +``` + +### Core Stack + +- **Framework**: Flutter with Material 3 (`useMaterial3: true`) +- **State Management**: Riverpod with `StreamProvider`, `StateNotifier`, and `FutureProvider` +- **Routing**: Go Router with auth-based redirects and ShellRoute for layout +- **Backend**: Supabase (PostgreSQL + Auth + Realtime) + +### UI/UX Design System (Hybrid M3/M2) + +Strictly follow this hybrid approach: +- **Base Theme**: `useMaterial3: true`. Use M3 for ColorScheme (`ColorScheme.fromSeed`), Typography, Navigation Bars, and Buttons. +- **Cards & Surfaces (The M2 Exception)**: + - Do **NOT** use flat M3 cards. + - **Use Material 2 style elevation**: Cards must have visible shadows (`elevation: 2` to `4`) and distinct borders/colors to separate them from the background. + - Purpose: To create clear separation of content in lists and boards. +- **Typography**: Strict adherence to `TextTheme` variables (e.g., `titleLarge`, `bodyMedium`); **NO** hardcoded font sizes. + +### Data Fetching & Search (Critical) + +**Server-Side Pagination**: +- **ALWAYS** implement server-side pagination using Supabase `.range(start, end)`. +- Do **NOT** fetch all rows and filter locally. +- Standard page size: 50 items (Desktop), Infinite Scroll chunks (Mobile). + +**Full Text Search (FTS)**: +- Use Supabase `.textSearch()` or `.ilike()` on the server side. +- Search state must be managed in a Riverpod provider (e.g., `searchQueryProvider`) and passed to the repository stream. + +**Real-Time Data**: +- Use `StreamProvider` for live updates, but ensure streams respect current search/filter parameters. + +### Data Flow Pattern + +1. **Supabase Queries**: Server-side pagination and search via `.range()` and `.textSearch()` +2. **Providers** (`lib/providers/`) expose data via `StreamProvider` for real-time updates +3. **Providers** handle role-based data filtering (admin/dispatcher/it_staff have global access; standard users are filtered by office assignments) +4. **Widgets** consume providers using `ConsumerWidget` or `ConsumerStatefulWidget` +5. **Controllers** (`*Controller` classes) handle mutations (create/update/delete) + +### Debugging & Troubleshooting Protocol + +- **RLS vs. Logic**: + - If data returns empty or incomplete, **DO NOT** assume it is a Row Level Security (RLS) issue immediately. + - **Step 1**: Check the query logic, specifically `.range()` offsets and `.eq()` filters. + - **Step 2**: Verify the user role and search term binding. + - **Step 3**: Only check RLS if specific error codes (`401`, `403`, `PGRST`) are returned. + +### Key Patterns + +**Role-Based Access Control**: User roles (`admin`, `dispatcher`, `it_staff`, `standard`) determine data visibility and available UI sections. See `lib/routing/app_router.dart` for routing guards and `lib/providers/` for data filters. + +**TasQAdaptiveList**: + - **Mobile**: Tile-based list with infinite scroll listeners. + - **Desktop**: Data Table with paginated footer. + - **Input**: Requires a reactive data source (`Stream>`) that responds to pagination/search providers. + +**Responsive Design**: +- **Support**: Mobile (<600px), Tablet (600-1024px), Desktop (>1024px) +- **Implementation**: `LayoutBuilder` or `ResponsiveWrapper` +- **Mobile**: Single-column, Infinite Scroll, Bottom Navigation +- **Desktop**: Multi-column (Row/Grid), Pagination Controls, Sidebar Navigation + +**Hybrid M3/M2 Theme**: See `lib/theme/app_theme.dart`: +- Use Material 3 for ColorScheme, Typography, Navigation Bars +- Use Material 2 elevation (shadows) for Cards to create visual separation + +### Important Providers + +| Provider | Purpose | +|----------|---------| +| `authStateChangesProvider` | Auth state stream | +| `sessionProvider` | Current session | +| `supabaseClientProvider` | Supabase client instance | +| `currentProfileProvider` | Current user's profile with role | +| `ticketsProvider` | User's accessible tickets (filtered by office) | +| `tasksProvider` | User's accessible tasks | + +### Time Handling + +All timestamps use `Asia/Manila` timezone via the `timezone` package. Use `AppTime.parse()` for parsing and `AppTime.format()` for display. See `lib/utils/app_time.dart`. + +## UI Conventions + +- **Zero Overflow Policy**: Wrap all text in `Flexible`/`Expanded` within `Row`/`Flex`. Use `SingleChildScrollView` for tall layouts. +- **Responsive Breakpoints**: Mobile (<600px), Tablet (600-1024px), Desktop (>1024px) +- **Mono Text**: Technical data uses `MonoText` widget (defined in `lib/theme/app_typography.dart`) +- **Status Pills**: Use `StatusPill` widget for status display + +## Project Conventions + +- **Testing**: Mandatory for all widgets/providers/repositories. +- **Mocking**: Use `mockito` for Supabase client mocking. +- **Documentation**: DartDoc (`///`) for public APIs. Explain *Why*, not just *How*. +- **Environment**: `.env` file manages `SUPABASE_URL` and `SUPABASE_ANON_KEY`. +- **Audio**: Notification sounds via `audioplayers`. +- **Key Files**: + - `lib/app.dart`: App setup/theme. + - `lib/routing/app_router.dart`: Routing & Auth guards. + - `lib/providers/`: State management. + +## Testing Strategy + +- Tests located in `test/` directory +- Use Riverpod's `ProviderScope` with `overrideWith` for mocking +- Mock Supabase client using `SupabaseClient('http://localhost', 'test-key')` +- Layout tests use ` tester.pump()` with 16ms delay for animations + +## Project Structure + +``` +lib/ +├── app.dart # App entrypoint (theme/router) +├── main.dart # Widget initialization +├── models/ # Data models (fromMap/toJson) +├── providers/ # Riverpod providers & controllers +├── routing/ +│ └── app_router.dart # Go Router config with auth guards +├── screens/ # Screen widgets +│ ├── admin/ # Admin-only screens +│ ├── auth/ # Login/Signup +│ ├── dashboard/ # Dashboard +│ ├── notifications/ # Notification center +│ ├── tasks/ # Task management +│ ├── tickets/ # Ticket management +│ ├── teams/ # Team management +│ ├── workforce/ # Workforce management +│ └── shared/ # Shared components +├── theme/ # AppTheme, typography +├── utils/ # Utilities (AppTime) +└── widgets/ # Reusable widgets +``` + diff --git a/lib/providers/admin_user_provider.dart b/lib/providers/admin_user_provider.dart index c7f14254..eedcc259 100644 --- a/lib/providers/admin_user_provider.dart +++ b/lib/providers/admin_user_provider.dart @@ -22,11 +22,7 @@ class AdminUserQuery { /// Full text search query. final String searchQuery; - AdminUserQuery copyWith({ - int? offset, - int? limit, - String? searchQuery, - }) { + AdminUserQuery copyWith({int? offset, int? limit, String? searchQuery}) { return AdminUserQuery( offset: offset ?? this.offset, limit: limit ?? this.limit, @@ -35,7 +31,9 @@ class AdminUserQuery { } } -final adminUserQueryProvider = StateProvider((ref) => const AdminUserQuery()); +final adminUserQueryProvider = StateProvider( + (ref) => const AdminUserQuery(), +); final adminUserControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); @@ -77,66 +75,111 @@ class AdminUserController { await _client.from('profiles').update({'role': role}).eq('id', userId); } + /// Password administration — forwarded to the admin Edge Function. Future setPassword({ required String userId, required String password, }) async { - await _invokeAdminFunction( - action: 'set_password', - payload: {'userId': userId, 'password': password}, - ); - } - - Future setLock({required String userId, required bool locked}) async { - await _invokeAdminFunction( - action: 'set_lock', - payload: {'userId': userId, 'locked': locked}, - ); - } - - Future fetchStatus(String userId) async { - final data = await _invokeAdminFunction( - action: 'get_user', - payload: {'userId': userId}, - ); - final user = (data as Map)['user'] as Map; - final bannedUntilRaw = user['banned_until'] as String?; - final bannedUntilParsed = bannedUntilRaw == null - ? null - : DateTime.tryParse(bannedUntilRaw); - return AdminUserStatus( - email: user['email'] as String?, - bannedUntil: bannedUntilParsed == null - ? null - : AppTime.toAppTime(bannedUntilParsed), - ); - } - - Future _invokeAdminFunction({ - required String action, - required Map payload, - }) async { + final payload = { + 'action': 'set_password', + 'userId': userId, + 'password': password, + }; + final accessToken = _client.auth.currentSession?.accessToken; final response = await _client.functions.invoke( 'admin_user_management', - body: {'action': action, ...payload}, + body: payload, + headers: accessToken == null + ? null + : {'Authorization': 'Bearer $accessToken'}, ); if (response.status != 200) { - throw Exception(_extractErrorMessage(response.data)); + throw Exception(response.data ?? 'Failed to reset password'); } - return response.data; } - String _extractErrorMessage(dynamic data) { - if (data is Map) { - final error = data['error']; - if (error is String && error.trim().isNotEmpty) { - return error; - } - final message = data['message']; - if (message is String && message.trim().isNotEmpty) { - return message; - } + /// Set/unset a user's ban/lock via the admin Edge Function (preferred). + Future setLock({required String userId, required bool locked}) async { + final payload = {'action': 'set_lock', 'userId': userId, 'locked': locked}; + final accessToken = _client.auth.currentSession?.accessToken; + final response = await _client.functions.invoke( + 'admin_user_management', + body: payload, + headers: accessToken == null + ? null + : {'Authorization': 'Bearer $accessToken'}, + ); + if (response.status != 200) { + throw Exception(response.data ?? 'Failed to update lock state'); } - return 'Admin request failed.'; + } + + /// Fetch user email + banned state from the admin Edge Function (auth.user). + Future fetchStatus(String userId) async { + final payload = {'action': 'get_user', 'userId': userId}; + final accessToken = _client.auth.currentSession?.accessToken; + final response = await _client.functions.invoke( + 'admin_user_management', + body: payload, + headers: accessToken == null + ? null + : {'Authorization': 'Bearer $accessToken'}, + ); + if (response.status != 200) { + return AdminUserStatus(email: null, bannedUntil: null); + } + final data = response.data; + final user = (data is Map) + ? (data['user'] as Map?) + : null; + final email = user?['email'] as String?; + DateTime? bannedUntil; + final bannedRaw = user?['banned_until']; + if (bannedRaw is String) { + bannedUntil = DateTime.tryParse(bannedRaw); + } else if (bannedRaw is DateTime) { + bannedUntil = bannedRaw; + } + return AdminUserStatus( + email: email, + bannedUntil: bannedUntil == null ? null : AppTime.toAppTime(bannedUntil), + ); + } + + /// Server-side paginated listing via Edge Function (returns auth + profile light view). + Future>> listUsers(AdminUserQuery q) async { + final payload = { + 'action': 'list_users', + 'offset': q.offset, + 'limit': q.limit, + 'searchQuery': q.searchQuery, + }; + final accessToken = _client.auth.currentSession?.accessToken; + final response = await _client.functions.invoke( + 'admin_user_management', + body: payload, + headers: accessToken == null + ? null + : {'Authorization': 'Bearer $accessToken'}, + ); + if (response.status != 200) { + throw Exception(response.data ?? 'Failed to list users'); + } + final users = (response.data is Map && response.data['users'] is List) + ? (response.data['users'] as List).cast>() + : >[]; + return users; } } + +final adminUserStatusProvider = FutureProvider.family + .autoDispose((ref, userId) { + return ref.watch(adminUserControllerProvider).fetchStatus(userId); + }); + +final adminUsersProvider = + FutureProvider.autoDispose>>((ref) { + final q = ref.watch(adminUserQueryProvider); + final ctrl = ref.watch(adminUserControllerProvider); + return ctrl.listUsers(q); + }); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 79d27a68..26bab73f 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -4,6 +4,8 @@ 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'; @@ -142,6 +144,14 @@ final appRouterProvider = Provider((ref) { class RouterNotifier extends ChangeNotifier { RouterNotifier(this.ref) { _authSub = ref.listen(authStateChangesProvider, (previous, next) { + // Enforce app-level profile lock when a session becomes available. + next.whenData((authState) { + final session = authState.session; + if (session != null) { + // Fire-and-forget enforcement (best-effort client-side sign-out) + enforceLockForCurrentUser(ref.read(supabaseClientProvider)); + } + }); notifyListeners(); }); _profileSub = ref.listen(currentProfileProvider, (previous, next) { diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index 169b09d6..73f643c7 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -10,6 +10,7 @@ import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../theme/app_surfaces.dart'; import '../../providers/user_offices_provider.dart'; + import '../../utils/app_time.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; @@ -36,14 +37,8 @@ class _UserManagementScreenState extends ConsumerState { String? _selectedUserId; String? _selectedRole; - Set _selectedOfficeIds = {}; - AdminUserStatus? _selectedStatus; + final Set _selectedOfficeIds = {}; bool _isSaving = false; - bool _isStatusLoading = false; - final Map _statusCache = {}; - final Set _statusLoading = {}; - final Set _statusErrors = {}; - Set _prefetchedUserIds = {}; @override void dispose() { @@ -106,8 +101,6 @@ class _UserManagementScreenState extends ConsumerState { final assignments = assignmentsAsync.valueOrNull ?? []; final messages = messagesAsync.valueOrNull ?? []; - _prefetchStatuses(profiles); - final lastActiveByUser = {}; for (final message in messages) { final senderId = message.senderId; @@ -168,12 +161,12 @@ class _UserManagementScreenState extends ConsumerState { TasQColumn( header: 'Email', cellBuilder: (context, profile) { - final status = _statusCache[profile.id]; - final hasError = _statusErrors.contains(profile.id); - final email = hasError - ? 'Unavailable' - : (status?.email ?? 'Unknown'); - return Text(email); + final statusAsync = ref.watch(adminUserStatusProvider(profile.id)); + return statusAsync.when( + data: (s) => Text(s.email ?? 'Unknown'), + loading: () => const Text('Loading...'), + error: (_, __) => const Text('Unknown'), + ); }, ), TasQColumn( @@ -190,11 +183,15 @@ class _UserManagementScreenState extends ConsumerState { TasQColumn( header: 'Status', cellBuilder: (context, profile) { - final status = _statusCache[profile.id]; - final hasError = _statusErrors.contains(profile.id); - final isLoading = _statusLoading.contains(profile.id); - final statusLabel = _userStatusLabel(status, hasError, isLoading); - return _StatusBadge(label: statusLabel); + final statusAsync = ref.watch(adminUserStatusProvider(profile.id)); + return statusAsync.when( + data: (s) { + final statusLabel = s.isLocked ? 'Locked' : 'Active'; + return _StatusBadge(label: statusLabel); + }, + loading: () => _StatusBadge(label: 'Loading'), + error: (_, __) => _StatusBadge(label: 'Unknown'), + ); }, ), TasQColumn( @@ -209,13 +206,9 @@ class _UserManagementScreenState extends ConsumerState { _showUserDialog(context, profile, offices, assignments), mobileTileBuilder: (context, profile, actions) { final label = profile.fullName.isEmpty ? profile.id : profile.fullName; - final status = _statusCache[profile.id]; - final hasError = _statusErrors.contains(profile.id); - final isLoading = _statusLoading.contains(profile.id); - final email = hasError ? 'Unavailable' : (status?.email ?? 'Unknown'); + final statusAsync = ref.watch(adminUserStatusProvider(profile.id)); final officesAssigned = officeCountByUser[profile.id] ?? 0; final lastActive = lastActiveByUser[profile.id]; - final statusLabel = _userStatusLabel(status, hasError, isLoading); return Card( child: ListTile( @@ -231,10 +224,19 @@ class _UserManagementScreenState extends ConsumerState { Text('Last active: ${_formatLastActiveLabel(lastActive)}'), const SizedBox(height: 4), MonoText('ID ${profile.id}'), - Text('Email: $email'), + statusAsync.when( + data: (s) => Text('Email: ${s.email ?? 'Unknown'}'), + loading: () => const Text('Email: Loading...'), + error: (_, __) => const Text('Email: Unknown'), + ), ], ), - trailing: _StatusBadge(label: statusLabel), + trailing: statusAsync.when( + data: (s) => + _StatusBadge(label: s.isLocked ? 'Locked' : 'Active'), + loading: () => _StatusBadge(label: 'Loading'), + error: (_, __) => _StatusBadge(label: 'Unknown'), + ), onTap: () => _showUserDialog(context, profile, offices, assignments), ), @@ -279,7 +281,6 @@ class _UserManagementScreenState extends ConsumerState { List offices, List assignments, ) async { - await _selectUser(profile); final currentOfficeIds = assignments .where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) @@ -319,44 +320,6 @@ class _UserManagementScreenState extends ConsumerState { ); } - void _ensureStatusLoaded(String userId) { - if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) { - return; - } - _statusLoading.add(userId); - _statusErrors.remove(userId); - ref - .read(adminUserControllerProvider) - .fetchStatus(userId) - .then((status) { - if (!mounted) return; - setState(() { - _statusCache[userId] = status; - _statusLoading.remove(userId); - }); - }) - .catchError((_) { - if (!mounted) return; - setState(() { - _statusLoading.remove(userId); - _statusErrors.add(userId); - }); - }); - } - - void _prefetchStatuses(List profiles) { - final ids = profiles.map((profile) => profile.id).toSet(); - final missing = ids.difference(_prefetchedUserIds); - if (missing.isEmpty) return; - _prefetchedUserIds = ids; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - for (final userId in missing) { - _ensureStatusLoaded(userId); - } - }); - } - Widget _buildUserForm( BuildContext context, Profile profile, @@ -383,7 +346,67 @@ class _UserManagementScreenState extends ConsumerState { decoration: const InputDecoration(labelText: 'Role'), ), const SizedBox(height: 12), - _buildStatusRow(profile), + + // Email and lock status are retrieved from auth via Edge Function / admin API. + Consumer( + builder: (context, ref, _) { + final statusAsync = ref.watch(adminUserStatusProvider(profile.id)); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + statusAsync.when( + data: (s) => Text( + 'Email: ${s.email ?? 'Unknown'}', + style: Theme.of(context).textTheme.bodySmall, + ), + loading: () => Text( + 'Email: Loading...', + style: Theme.of(context).textTheme.bodySmall, + ), + error: (_, __) => Text( + 'Email: Unknown', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + onPressed: _isSaving + ? null + : () => _showPasswordResetDialog(profile.id), + icon: const Icon(Icons.password), + label: const Text('Reset password'), + ), + const SizedBox(width: 12), + statusAsync.when( + data: (s) => OutlinedButton.icon( + onPressed: _isSaving + ? null + : () => _toggleLock(profile.id, !s.isLocked), + icon: Icon(s.isLocked ? Icons.lock_open : Icons.lock), + label: Text(s.isLocked ? 'Unlock' : 'Lock'), + ), + loading: () => OutlinedButton.icon( + onPressed: null, + icon: const Icon(Icons.lock), + label: const Text('Loading...'), + ), + error: (_, __) => OutlinedButton.icon( + onPressed: _isSaving + ? null + : () => _toggleLock(profile.id, true), + icon: const Icon(Icons.lock), + label: const Text('Lock'), + ), + ), + ], + ), + ], + ); + }, + ), + const SizedBox(height: 16), Text('Offices', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), @@ -445,82 +468,6 @@ class _UserManagementScreenState extends ConsumerState { ); } - Widget _buildStatusRow(Profile profile) { - final email = _selectedStatus?.email; - final isLocked = _selectedStatus?.isLocked ?? false; - final lockLabel = isLocked ? 'Unlock' : 'Lock'; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Email: ${email ?? 'Loading...'}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 8), - Row( - children: [ - OutlinedButton.icon( - onPressed: _isStatusLoading - ? null - : () => _showPasswordResetDialog(profile.id), - icon: const Icon(Icons.password), - label: const Text('Reset password'), - ), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: _isStatusLoading - ? null - : () => _toggleLock(profile.id, !isLocked), - icon: Icon(isLocked ? Icons.lock_open : Icons.lock), - label: Text(lockLabel), - ), - ], - ), - ], - ); - } - - Future _selectUser(Profile profile) async { - setState(() { - _selectedUserId = profile.id; - _selectedRole = profile.role; - _fullNameController.text = profile.fullName; - _selectedStatus = null; - _isStatusLoading = true; - }); - - final assignments = ref.read(userOfficesProvider).valueOrNull ?? []; - final officeIds = assignments - .where((assignment) => assignment.userId == profile.id) - .map((assignment) => assignment.officeId) - .toSet(); - setState(() => _selectedOfficeIds = officeIds); - - try { - final status = await ref - .read(adminUserControllerProvider) - .fetchStatus(profile.id); - if (mounted) { - setState(() { - _selectedStatus = status; - _statusCache[profile.id] = status; - }); - } - } catch (_) { - if (mounted) { - setState( - () => - _selectedStatus = AdminUserStatus(email: null, bannedUntil: null), - ); - } - } finally { - if (mounted) { - setState(() => _isStatusLoading = false); - } - } - } - Future _saveChanges( BuildContext context, Profile profile, @@ -646,18 +593,24 @@ class _UserManagementScreenState extends ConsumerState { } Future _toggleLock(String userId, bool locked) async { - setState(() => _isStatusLoading = true); + setState(() => _isSaving = true); try { + // Use AdminUserController (Edge Function or direct DB) to lock/unlock. await ref .read(adminUserControllerProvider) .setLock(userId: userId, locked: locked); - final status = await ref - .read(adminUserControllerProvider) - .fetchStatus(userId); + + // Refresh profile streams so other UI updates observe the change. + ref.invalidate(profilesProvider); + ref.invalidate(currentProfileProvider); + if (!mounted) return; - setState(() => _selectedStatus = status); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locked ? 'User locked.' : 'User unlocked.')), + SnackBar( + content: Text( + locked ? 'User locked (app-level).' : 'User unlocked (app-level).', + ), + ), ); } catch (error) { if (!mounted) return; @@ -666,23 +619,12 @@ class _UserManagementScreenState extends ConsumerState { ).showSnackBar(SnackBar(content: Text('Lock update failed: $error'))); } finally { if (mounted) { - setState(() => _isStatusLoading = false); + setState(() => _isSaving = false); } } } } -String _userStatusLabel( - AdminUserStatus? status, - bool hasError, - bool isLoading, -) { - if (isLoading) return 'Loading'; - if (hasError) return 'Status error'; - if (status == null) return 'Unknown'; - return status.isLocked ? 'Locked' : 'Active'; -} - String _formatLastActiveLabel(DateTime? value) { if (value == null) return 'N/A'; final now = AppTime.now(); diff --git a/lib/utils/lock_enforcer.dart b/lib/utils/lock_enforcer.dart new file mode 100644 index 00000000..add129ee --- /dev/null +++ b/lib/utils/lock_enforcer.dart @@ -0,0 +1,23 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Call after sign-in and on app start to enforce app-level profile lock. +/// If the user's `profiles.is_locked` flag is true, this signs out the user. +Future enforceLockForCurrentUser(SupabaseClient supabase) async { + final user = supabase.auth.currentUser; + if (user == null) return; + + try { + final record = await supabase + .from('profiles') + .select('is_locked') + .eq('id', user.id) + .maybeSingle(); + + if (record == null) return; + if (record['is_locked'] == true) { + await supabase.auth.signOut(); + } + } catch (_) { + // swallow; enforcement is a best-effort client-side check + } +} diff --git a/supabase/functions/admin_user_management/index.ts b/supabase/functions/admin_user_management/index.ts index 4e39cfab..20c52bce 100644 --- a/supabase/functions/admin_user_management/index.ts +++ b/supabase/functions/admin_user_management/index.ts @@ -1,13 +1,29 @@ import { serve } from "https://deno.land/std@0.203.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +// CORS: allow browser-based web clients (adjust origin in production) +// Permit headers the supabase-js client sends (e.g. `apikey`, `x-client-info`) so +// browser preflight requests succeed. Keep origin wide for dev; in production +// prefer echoing the request origin and enabling credentials only when needed. +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info, x-requested-with, Origin, Accept", + "Access-Control-Max-Age": "3600", +}; + const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, }); serve(async (req) => { + // Handle CORS preflight quickly so browsers can call this function from localhost/dev + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + if (req.method != "POST") { return jsonResponse({ error: "Method not allowed" }, 405); } @@ -54,8 +70,51 @@ serve(async (req) => { const action = payload.action as string | undefined; const userId = payload.userId as string | undefined; - if (!action || !userId) { - return jsonResponse({ error: "Missing action or userId" }, 400); + if (!action) { + return jsonResponse({ error: "Missing action" }, 400); + } + // `list_users` does not require a target userId; other actions do. + if (action !== "list_users" && !userId) { + return jsonResponse({ error: "Missing userId" }, 400); + } + + if (action === "list_users") { + const offset = Number(payload.offset ?? 0); + const limit = Number(payload.limit ?? 50); + const searchQuery = (payload.searchQuery ?? "").toString().trim(); + + // Fetch paginated profiles first (enforce server-side pagination) + let profilesQuery = adminClient + .from("profiles") + .select("id, full_name, role") + .order("id", { ascending: true }) + .range(offset, offset + limit - 1); + + if (searchQuery.length > 0) { + profilesQuery = adminClient + .from("profiles") + .select("id, full_name, role") + .ilike("full_name", `%${searchQuery}%`) + .order("id", { ascending: true }) + .range(offset, offset + limit - 1); + } + + const { data: profiles, error: profilesError } = await profilesQuery; + if (profilesError) return jsonResponse({ error: profilesError.message }, 500); + + const users = []; + for (const p of (profiles ?? [])) { + const { data: userResp } = await adminClient.auth.admin.getUserById(p.id); + users.push({ + id: p.id, + full_name: p.full_name ?? null, + role: p.role ?? null, + email: userResp?.user?.email ?? null, + bannedUntil: userResp?.user?.banned_until ?? null, + }); + } + + return jsonResponse({ users }); } if (action == "get_user") { diff --git a/supabase/functions/teams_crud/index.ts b/supabase/functions/teams_crud/index.ts new file mode 100644 index 00000000..6dd2c7d6 --- /dev/null +++ b/supabase/functions/teams_crud/index.ts @@ -0,0 +1,188 @@ +import { serve } from "https://deno.land/std@0.203.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +// CORS: allow browser-based web clients (adjust origin in production) +// Permit headers the supabase-js client sends (e.g. `apikey`, `x-client-info`) so +// browser preflight requests succeed. Keep origin wide for dev; in production +// prefer echoing the request origin and enabling credentials only when needed. +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info, x-requested-with, Origin, Accept", + "Access-Control-Max-Age": "3600", +}; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + +serve(async (req) => { + // Handle CORS preflight quickly so browsers can call this function from localhost/dev + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + const anonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + if (!supabaseUrl || !anonKey || !serviceKey) { + return jsonResponse({ error: "Missing env configuration" }, 500); + } + + // Authenticate caller (must be admin) + const authHeader = req.headers.get("Authorization") ?? ""; + const token = authHeader.replace("Bearer ", "").trim(); + if (!token) return jsonResponse({ error: "Missing access token" }, 401); + + const authClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + + const { data: authData, error: authError } = await authClient.auth.getUser(); + if (authError || !authData?.user) return jsonResponse({ error: "Unauthorized" }, 401); + + // Use service role client for privileged DB ops + const adminClient = createClient(supabaseUrl, serviceKey); + + // Ensure caller is admin in profiles table + const { data: profile, error: profileError } = await adminClient + .from("profiles") + .select("role") + .eq("id", authData.user.id) + .maybeSingle(); + const role = (profile?.role ?? "").toString().toLowerCase(); + if (profileError || role !== "admin") return jsonResponse({ error: "Forbidden" }, 403); + + // Route request + const url = new URL(req.url); + const id = url.pathname.split("/").pop(); + + try { + if (req.method === "GET") { + if (id && id !== "teams") { + const { data, error } = await adminClient + .from("teams") + .select('*, team_members(*)') + .eq('id', id) + .maybeSingle(); + if (error) return jsonResponse({ error: error.message }, 400); + return jsonResponse({ team: data }); + } + + const { data, error } = await adminClient + .from("teams") + .select('*, team_members(*)') + .order('name'); + if (error) return jsonResponse({ error: error.message }, 400); + return jsonResponse({ teams: data }); + } + + if (req.method === "POST") { + // Support both: standard POST-create and POST with an 'action' parameter + const body = await req.json(); + const action = typeof body.action === 'string' ? body.action.toLowerCase() : 'create'; + + // Create (default POST) + if (action === 'create') { + const name = typeof body.name === 'string' ? body.name : undefined; + const leaderId = typeof body.leader_id === 'string' ? body.leader_id : undefined; + const officeIds = Array.isArray(body.office_ids) ? (body.office_ids as string[]) : []; + const members = Array.isArray(body.members) ? (body.members as string[]) : []; + + if (!name || !leaderId || officeIds.length === 0) { + return jsonResponse({ error: 'Missing required fields' }, 400); + } + + const { data: insertedTeam, error: insertError } = await adminClient + .from('teams') + .insert({ name, leader_id: leaderId, office_ids: officeIds }) + .select() + .single(); + if (insertError) return jsonResponse({ error: insertError.message }, 400); + + if (members.length > 0) { + const rows = members.map((u) => ({ team_id: (insertedTeam as any).id, user_id: u })); + const { error } = await adminClient.from('team_members').insert(rows); + if (error) return jsonResponse({ error: error.message }, 400); + } + + return jsonResponse({ team: insertedTeam }, 201); + } + + // Update (POST with action='update') + if (action === 'update') { + const teamId = typeof body.id === 'string' ? body.id : undefined; + if (!teamId) return jsonResponse({ error: 'Missing team id' }, 400); + const name = typeof body.name === 'string' ? body.name : undefined; + const leaderId = typeof body.leader_id === 'string' ? body.leader_id : undefined; + const officeIds = Array.isArray(body.office_ids) ? (body.office_ids as string[]) : []; + const members = Array.isArray(body.members) ? (body.members as string[]) : []; + + const { error: upErr } = await adminClient + .from('teams') + .update({ name, leader_id: leaderId, office_ids: officeIds }) + .eq('id', teamId); + if (upErr) return jsonResponse({ error: upErr.message }, 400); + + await adminClient.from('team_members').delete().eq('team_id', teamId); + if (members.length > 0) { + const rows = members.map((u) => ({ team_id: teamId, user_id: u })); + const { error } = await adminClient.from('team_members').insert(rows); + if (error) return jsonResponse({ error: error.message }, 400); + } + + const { data: updated, error: fetchErr } = await adminClient.from('teams').select().eq('id', teamId).maybeSingle(); + if (fetchErr) return jsonResponse({ error: fetchErr.message }, 400); + return jsonResponse({ team: updated }); + } + + // Delete (POST with action='delete') + if (action === 'delete') { + const teamId = typeof body.id === 'string' ? body.id : undefined; + if (!teamId) return jsonResponse({ error: 'Missing team id' }, 400); + await adminClient.from('team_members').delete().eq('team_id', teamId); + const { error } = await adminClient.from('teams').delete().eq('id', teamId); + if (error) return jsonResponse({ error: error.message }, 400); + return jsonResponse({ ok: true }); + } + + return jsonResponse({ error: 'Unknown POST action' }, 400); + } + + if (req.method === "PUT") { + if (!id || id === "teams") return jsonResponse({ error: "Missing team id" }, 400); + const body = await req.json(); + const name = body.name as string | undefined; + const leaderId = body.leader_id as string | undefined; + const officeIds = (body.office_ids as string[] | undefined) ?? []; + const members = (body.members as string[] | undefined) ?? []; + + const { error: upErr } = await adminClient.from('teams').update({ name, leader_id: leaderId, office_ids: officeIds }).eq('id', id); + if (upErr) return jsonResponse({ error: upErr.message }, 400); + + await adminClient.from('team_members').delete().eq('team_id', id); + if (members.length > 0) { + const rows = members.map((u) => ({ team_id: id, user_id: u })); + const { error } = await adminClient.from('team_members').insert(rows); + if (error) return jsonResponse({ error: error.message }, 400); + } + + const { data: updated, error: fetchErr } = await adminClient.from('teams').select().eq('id', id).maybeSingle(); + if (fetchErr) return jsonResponse({ error: fetchErr.message }, 400); + return jsonResponse({ team: updated }); + } + + if (req.method === "DELETE") { + if (!id || id === "teams") return jsonResponse({ error: "Missing team id" }, 400); + await adminClient.from('team_members').delete().eq('team_id', id); + const { error } = await adminClient.from('teams').delete().eq('id', id); + if (error) return jsonResponse({ error: error.message }, 400); + return jsonResponse({ ok: true }); + } + + return jsonResponse({ error: "Method not allowed" }, 405); + } catch (err) { + return jsonResponse({ error: (err as Error).message }, 500); + } +}); \ No newline at end of file diff --git a/supabase/migrations/20260211090000_add_reliever_ids_to_duty_schedules.sql b/supabase/migrations/20260211090000_add_reliever_ids_to_duty_schedules.sql new file mode 100644 index 00000000..d5d38041 --- /dev/null +++ b/supabase/migrations/20260211090000_add_reliever_ids_to_duty_schedules.sql @@ -0,0 +1,2 @@ +alter table public.duty_schedules + add column if not exists reliever_ids uuid[] default '{}'::uuid[]; diff --git a/supabase/migrations/20260217090000_enforce_it_staff_for_teams.sql b/supabase/migrations/20260217090000_enforce_it_staff_for_teams.sql new file mode 100644 index 00000000..994d613f --- /dev/null +++ b/supabase/migrations/20260217090000_enforce_it_staff_for_teams.sql @@ -0,0 +1,42 @@ +-- Enforce that team leaders and team members must be profiles with role = 'it_staff' + +-- Function: validate_team_leader_is_it_staff +create or replace function public.validate_team_leader_is_it_staff() +returns trigger language plpgsql as $$ +begin + if new.leader_id is not null then + perform 1 from public.profiles where id = new.leader_id and role = 'it_staff'; + if not found then + raise exception 'team leader must have role "it_staff"'; + end if; + end if; + return new; +end; +$$; + +-- Trigger on teams +drop trigger if exists trig_validate_team_leader on public.teams; +create trigger trig_validate_team_leader +before insert or update on public.teams +for each row execute function public.validate_team_leader_is_it_staff(); + + +-- Function: validate_team_member_is_it_staff +create or replace function public.validate_team_member_is_it_staff() +returns trigger language plpgsql as $$ +begin + if new.user_id is not null then + perform 1 from public.profiles where id = new.user_id and role = 'it_staff'; + if not found then + raise exception 'team member must have role "it_staff"'; + end if; + end if; + return new; +end; +$$; + +-- Trigger on team_members +drop trigger if exists trig_validate_team_member on public.team_members; +create trigger trig_validate_team_member +before insert or update on public.team_members +for each row execute function public.validate_team_member_is_it_staff(); diff --git a/supabase/migrations/20260217103000_rls_teams.sql b/supabase/migrations/20260217103000_rls_teams.sql new file mode 100644 index 00000000..32e68713 --- /dev/null +++ b/supabase/migrations/20260217103000_rls_teams.sql @@ -0,0 +1,49 @@ +-- Row-level security for teams and team_members + +-- Enable RLS on teams and team_members +ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.team_members ENABLE ROW LEVEL SECURITY; + +-- Allow only profiles with role = 'admin' to select/manage teams +CREATE POLICY "Admins can manage teams (select)" ON public.teams + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin' + ) + ); + +CREATE POLICY "Admins can manage teams (write)" ON public.teams + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin' + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin' + ) + ); + +-- Policies for team_members (admin-only management) +CREATE POLICY "Admins can manage team_members (select)" ON public.team_members + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin' + ) + ); + +CREATE POLICY "Admins can manage team_members (write)" ON public.team_members + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin' + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role = 'admin' + ) + ); diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index ef64fdca..431f4a24 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -9,6 +9,8 @@ import 'package:tasq/models/profile.dart'; import 'package:tasq/models/task.dart'; import 'package:tasq/models/ticket.dart'; import 'package:tasq/models/user_office.dart'; +import 'package:tasq/models/team.dart'; +import 'package:tasq/models/team_member.dart'; import 'package:tasq/providers/notifications_provider.dart'; import 'package:tasq/providers/profile_provider.dart'; @@ -22,6 +24,8 @@ import 'package:tasq/screens/admin/user_management_screen.dart'; import 'package:tasq/screens/tasks/tasks_list_screen.dart'; import 'package:tasq/screens/tickets/tickets_list_screen.dart'; import 'package:tasq/screens/tickets/ticket_detail_screen.dart'; +import 'package:tasq/screens/teams/teams_screen.dart'; +import 'package:tasq/providers/teams_provider.dart'; // Test double for NotificationsController so widget tests don't initialize // a real Supabase client. @@ -175,6 +179,109 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('Teams screen renders without layout exceptions', ( + tester, + ) async { + await _setSurfaceSize(tester, const Size(1024, 800)); + await _pumpScreen( + tester, + const TeamsScreen(), + overrides: [ + ...baseOverrides(), + teamsProvider.overrideWith((ref) => Stream.value(const [])), + teamMembersProvider.overrideWith( + (ref) => Stream.value(const []), + ), + ], + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.takeException(), isNull); + }); + + testWidgets('Add Team dialog requires at least one office', (tester) async { + await _setSurfaceSize(tester, const Size(600, 800)); + await _pumpScreen( + tester, + const TeamsScreen(), + overrides: [ + ...baseOverrides(), + teamsProvider.overrideWith((ref) => Stream.value(const [])), + teamMembersProvider.overrideWith( + (ref) => Stream.value(const []), + ), + ], + ); + + // Open Add Team dialog + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // Enter name + final nameField = find.byType(TextField).first; + await tester.enterText(nameField, 'Alpha Team'); + await tester.pumpAndSettle(); + + // Select leader (it_staff) + final leaderDropdown = find.widgetWithText( + DropdownButtonFormField, + 'Team Leader', + ); + await tester.tap(leaderDropdown); + await tester.pumpAndSettle(); + await tester.tap(find.text('Jamie Tech').last); + await tester.pumpAndSettle(); + + // Try to add without selecting offices -> should show validation SnackBar + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + expect( + find.text('Assign at least one office to the team'), + findsOneWidget, + ); + }); + + testWidgets('Add Team dialog: opening Offices dropdown does not overflow', ( + tester, + ) async { + await _setSurfaceSize(tester, const Size(600, 800)); + await _pumpScreen( + tester, + const TeamsScreen(), + overrides: [ + ...baseOverrides(), + teamsProvider.overrideWith((ref) => Stream.value(const [])), + teamMembersProvider.overrideWith( + (ref) => Stream.value(const []), + ), + ], + ); + + // Open Add Team dialog + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // Find Offices InputDecorator and tap its Select ActionChip + final officesField = find.widgetWithText(InputDecorator, 'Offices'); + expect(officesField, findsOneWidget); + final selectChip = find.descendant( + of: officesField, + matching: find.widgetWithText(ActionChip, 'Select'), + ); + expect(selectChip, findsOneWidget); + + await tester.tap(selectChip); + await tester.pumpAndSettle(); + + // Inner dialog should appear and no layout overflow exceptions should be thrown + expect(find.text('Select Offices'), findsOneWidget); + expect(tester.takeException(), isNull); + + // The list should contain the sample office + expect(find.widgetWithText(CheckboxListTile, 'HQ'), findsOneWidget); + }); + testWidgets('User management renders without layout exceptions', ( tester, ) async { diff --git a/test/searchable_multi_select_dropdown_test.dart b/test/searchable_multi_select_dropdown_test.dart new file mode 100644 index 00000000..6326644c --- /dev/null +++ b/test/searchable_multi_select_dropdown_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:tasq/models/office.dart'; +import 'package:tasq/widgets/multi_select_picker.dart'; + +void main() { + testWidgets( + 'SearchableMultiSelectDropdown: search / select-all / chips update', + (WidgetTester tester) async { + final offices = [ + Office(id: 'o1', name: 'HQ'), + Office(id: 'o2', name: 'Branch'), + Office(id: 'o3', name: 'Remote'), + ]; + + // Host state for selected IDs + List selected = ['o1']; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: MultiSelectPicker( + label: 'Offices', + items: offices, + selectedIds: selected, + getId: (o) => o.id, + getLabel: (o) => o.name, + onChanged: (ids) => setState(() => selected = ids), + ), + ); + }, + ), + ), + ), + ); + + // Initially the selected chip for 'HQ' must be visible + expect(find.text('HQ'), findsOneWidget); + expect(selected, equals(['o1'])); + + // Open dropdown dialog + await tester.tap(find.text('Select')); + await tester.pumpAndSettle(); + + // Dialog title present + expect(find.text('Select Offices'), findsOneWidget); + + // All items are visible in the list initially + expect(find.widgetWithText(CheckboxListTile, 'HQ'), findsOneWidget); + expect(find.widgetWithText(CheckboxListTile, 'Branch'), findsOneWidget); + expect(find.widgetWithText(CheckboxListTile, 'Remote'), findsOneWidget); + + // Search for 'Branch' -> only Branch should remain in the list + final searchField = find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ); + expect(searchField, findsOneWidget); + await tester.enterText(searchField, 'Branch'); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(CheckboxListTile, 'Branch'), findsOneWidget); + expect(find.widgetWithText(CheckboxListTile, 'HQ'), findsNothing); + expect(find.widgetWithText(CheckboxListTile, 'Remote'), findsNothing); + + // Clear search, then use Select All + await tester.enterText(searchField, ''); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(CheckboxListTile, 'Select All')); + await tester.pumpAndSettle(); + + // Press Done to apply selection + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + // Now all chips should be present and selected list updated + expect(find.text('HQ'), findsOneWidget); + expect(find.text('Branch'), findsOneWidget); + expect(find.text('Remote'), findsOneWidget); + expect(selected.toSet(), equals({'o1', 'o2', 'o3'})); + + // Re-open dialog and uncheck 'HQ' + await tester.tap(find.text('Select')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(CheckboxListTile, 'HQ')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + // HQ chip should be removed, others remain + expect(find.text('HQ'), findsNothing); + expect(find.text('Branch'), findsOneWidget); + expect(find.text('Remote'), findsOneWidget); + expect(selected.toSet(), equals({'o2', 'o3'})); + }, + ); +} diff --git a/test/services/admin_profile_service_test.dart b/test/services/admin_profile_service_test.dart new file mode 100644 index 00000000..3e7364bd --- /dev/null +++ b/test/services/admin_profile_service_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/services/admin_profile_service.dart'; + +class _FakeFromBuilder { + Map? lastUpdate; + String? lastEqCol; + String? lastEqVal; + + _FakeFromBuilder update(Map v) { + lastUpdate = v; + return this; + } + + _FakeFromBuilder eq(String col, String val) { + lastEqCol = col; + lastEqVal = val; + return this; + } + + _FakeFromBuilder select() => this; + Future maybeSingle() async => {'error': null, 'data': lastUpdate}; +} + +class _FakeSupabaseClient { + final _FakeFromBuilder builder = _FakeFromBuilder(); + dynamic from(String _) => builder; +} + +void main() { + test('setUserLock updates is_locked on profiles', () async { + final fake = _FakeSupabaseClient(); + final svc = AdminProfileService(fake as dynamic); + await svc.setUserLock('user-123', true); + expect(fake.builder.lastUpdate, {'is_locked': true}); + expect(fake.builder.lastEqCol, 'id'); + expect(fake.builder.lastEqVal, 'user-123'); + }); +} diff --git a/test/teams_screen_test.dart b/test/teams_screen_test.dart new file mode 100644 index 00000000..a2df8186 --- /dev/null +++ b/test/teams_screen_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'package:tasq/models/office.dart'; +import 'package:tasq/models/profile.dart'; +import 'package:tasq/models/team.dart'; +import 'package:tasq/models/team_member.dart'; +import 'package:tasq/providers/teams_provider.dart'; +import 'package:tasq/providers/profile_provider.dart'; +import 'package:tasq/providers/tickets_provider.dart'; +import 'package:tasq/screens/teams/teams_screen.dart'; +import 'package:tasq/providers/supabase_provider.dart'; +import 'package:tasq/widgets/multi_select_picker.dart'; + +SupabaseClient _fakeSupabaseClient() => + SupabaseClient('http://localhost', 'test-key'); + +void main() { + final office = Office(id: 'office-1', name: 'HQ'); + final admin = Profile(id: 'user-1', role: 'admin', fullName: 'Alex Admin'); + final tech = Profile(id: 'user-2', role: 'it_staff', fullName: 'Jamie Tech'); + + List baseOverrides() { + return [ + supabaseClientProvider.overrideWithValue(_fakeSupabaseClient()), + currentProfileProvider.overrideWith((ref) => Stream.value(admin)), + profilesProvider.overrideWith((ref) => Stream.value([admin, tech])), + officesProvider.overrideWith((ref) => Stream.value([office])), + teamsProvider.overrideWith((ref) => Stream.value(const [])), + teamMembersProvider.overrideWith( + (ref) => Stream.value(const []), + ), + ]; + } + + testWidgets('Add Team dialog: leader dropdown shows only it_staff', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(600, 800)); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ProviderScope( + overrides: baseOverrides(), + child: const MaterialApp(home: Scaffold(body: TeamsScreen())), + ), + ); + + // Open Add Team dialog + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // Open the dropdown overlay and assert only the it_staff option is shown. + final leaderDropdown = find.widgetWithText( + DropdownButtonFormField, + 'Team Leader', + ); + expect(leaderDropdown, findsOneWidget); + + await tester.tap(leaderDropdown); + await tester.pumpAndSettle(); + + // The dropdown overlay should show the it_staff option and not the admin. + expect(find.text('Jamie Tech'), findsOneWidget); + expect(find.text('Alex Admin'), findsNothing); + }); + + testWidgets('Add Team dialog: Team Members picker shows only it_staff', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(600, 800)); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ProviderScope( + overrides: baseOverrides(), + child: const MaterialApp(home: Scaffold(body: TeamsScreen())), + ), + ); + + // Open Add Team dialog + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // Inspect the MultiSelectPicker widget for 'Team Members' directly + final pickerFinder = find.byWidgetPredicate( + (w) => w is MultiSelectPicker && w.label == 'Team Members', + ); + expect(pickerFinder, findsOneWidget); + final picker = tester.widget>(pickerFinder); + final labels = picker.items.map(picker.getLabel).toList(); + expect(labels, contains('Jamie Tech')); + expect(labels, isNot(contains('Alex Admin'))); + }); + + testWidgets( + 'Add Team dialog uses fixed width on desktop and bottom-sheet on mobile', + (WidgetTester tester) async { + // Desktop -> AlertDialog constrained to max width + await tester.binding.setSurfaceSize(const Size(1024, 800)); + addTearDown(() async => await tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ProviderScope( + overrides: baseOverrides(), + child: const MaterialApp(home: Scaffold(body: TeamsScreen())), + ), + ); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + final dialogSize = tester.getSize(find.byType(AlertDialog)); + expect(dialogSize.width, lessThanOrEqualTo(720)); + + // Close desktop dialog + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Mobile -> bottom sheet presentation + await tester.binding.setSurfaceSize(const Size(600, 800)); + await tester.pumpWidget( + ProviderScope( + overrides: baseOverrides(), + child: const MaterialApp(home: Scaffold(body: TeamsScreen())), + ), + ); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsWidgets); + expect(find.text('Team Name'), findsOneWidget); + }, + ); +}