chore: remove unused profile lock migration and admin_profile_service

This commit is contained in:
Marc Rejohn Castillano 2026-02-18 18:13:10 +08:00
parent e3afd51410
commit 04fd874a48
18 changed files with 1471 additions and 220 deletions

View File

@ -0,0 +1,6 @@
{
"enabledMcpjsonServers": [
"supabase"
],
"enableAllProjectMcpServers": true
}

174
.github/copilot-instructions.md vendored Normal file
View File

@ -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 24)
- `cardTheme.shape.borderRadius`: 1216
- `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 24 and borderRadius 1216.
- 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<List<T>>`) 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
```

2
.vscode/mcp.json vendored
View File

@ -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": []

162
CLAUDE.md Normal file
View File

@ -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<List<T>>`) 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
```

162
GEMINI.md Normal file
View File

@ -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<List<T>>`) 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
```

View File

@ -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<AdminUserQuery>((ref) => const AdminUserQuery());
final adminUserQueryProvider = StateProvider<AdminUserQuery>(
(ref) => const AdminUserQuery(),
);
final adminUserControllerProvider = Provider<AdminUserController>((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<void> setPassword({
required String userId,
required String password,
}) async {
await _invokeAdminFunction(
action: 'set_password',
payload: {'userId': userId, 'password': password},
);
}
Future<void> setLock({required String userId, required bool locked}) async {
await _invokeAdminFunction(
action: 'set_lock',
payload: {'userId': userId, 'locked': locked},
);
}
Future<AdminUserStatus> fetchStatus(String userId) async {
final data = await _invokeAdminFunction(
action: 'get_user',
payload: {'userId': userId},
);
final user = (data as Map<String, dynamic>)['user'] as Map<String, dynamic>;
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<dynamic> _invokeAdminFunction({
required String action,
required Map<String, dynamic> 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<String, dynamic>) {
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<void> 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<AdminUserStatus> 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<String, dynamic>)
? (data['user'] as Map<String, dynamic>?)
: 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>>()
: <Map<String, dynamic>>[];
return users;
}
}
final adminUserStatusProvider = FutureProvider.family
.autoDispose<AdminUserStatus, String>((ref, userId) {
return ref.watch(adminUserControllerProvider).fetchStatus(userId);
});
final adminUsersProvider =
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
final q = ref.watch(adminUserQueryProvider);
final ctrl = ref.watch(adminUserControllerProvider);
return ctrl.listUsers(q);
});

View File

@ -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<GoRouter>((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) {

View File

@ -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<UserManagementScreen> {
String? _selectedUserId;
String? _selectedRole;
Set<String> _selectedOfficeIds = {};
AdminUserStatus? _selectedStatus;
final Set<String> _selectedOfficeIds = {};
bool _isSaving = false;
bool _isStatusLoading = false;
final Map<String, AdminUserStatus> _statusCache = {};
final Set<String> _statusLoading = {};
final Set<String> _statusErrors = {};
Set<String> _prefetchedUserIds = {};
@override
void dispose() {
@ -106,8 +101,6 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
final assignments = assignmentsAsync.valueOrNull ?? [];
final messages = messagesAsync.valueOrNull ?? [];
_prefetchStatuses(profiles);
final lastActiveByUser = <String, DateTime>{};
for (final message in messages) {
final senderId = message.senderId;
@ -168,12 +161,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
TasQColumn<Profile>(
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<Profile>(
@ -190,11 +183,15 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
TasQColumn<Profile>(
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<Profile>(
@ -209,13 +206,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
_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<UserManagementScreen> {
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<UserManagementScreen> {
List<Office> offices,
List<UserOffice> 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<UserManagementScreen> {
);
}
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<Profile> 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<UserManagementScreen> {
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<UserManagementScreen> {
);
}
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<void> _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<bool> _saveChanges(
BuildContext context,
Profile profile,
@ -646,18 +593,24 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
}
Future<void> _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<UserManagementScreen> {
).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();

View File

@ -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<void> 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
}
}

View File

@ -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<string, string> = {
"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") {

View File

@ -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<string, string> = {
"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);
}
});

View File

@ -0,0 +1,2 @@
alter table public.duty_schedules
add column if not exists reliever_ids uuid[] default '{}'::uuid[];

View File

@ -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();

View File

@ -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'
)
);

View File

@ -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 <Team>[])),
teamMembersProvider.overrideWith(
(ref) => Stream.value(const <TeamMember>[]),
),
],
);
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 <Team>[])),
teamMembersProvider.overrideWith(
(ref) => Stream.value(const <TeamMember>[]),
),
],
);
// 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<String>,
'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 <Team>[])),
teamMembersProvider.overrideWith(
(ref) => Stream.value(const <TeamMember>[]),
),
],
);
// 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 {

View File

@ -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<String> selected = ['o1'];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: MultiSelectPicker<Office>(
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'}));
},
);
}

View File

@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/services/admin_profile_service.dart';
class _FakeFromBuilder {
Map<String, dynamic>? lastUpdate;
String? lastEqCol;
String? lastEqVal;
_FakeFromBuilder update(Map<String, dynamic> v) {
lastUpdate = v;
return this;
}
_FakeFromBuilder eq(String col, String val) {
lastEqCol = col;
lastEqVal = val;
return this;
}
_FakeFromBuilder select() => this;
Future<dynamic> 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');
});
}

139
test/teams_screen_test.dart Normal file
View File

@ -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<Override> 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 <Team>[])),
teamMembersProvider.overrideWith(
(ref) => Stream.value(const <TeamMember>[]),
),
];
}
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<String>,
'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<Profile> && w.label == 'Team Members',
);
expect(pickerFinder, findsOneWidget);
final picker = tester.widget<MultiSelectPicker<Profile>>(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);
},
);
}