chore: remove unused profile lock migration and admin_profile_service
This commit is contained in:
parent
e3afd51410
commit
04fd874a48
6
.claude/settings.local.json
Normal file
6
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"enabledMcpjsonServers": [
|
||||
"supabase"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
174
.github/copilot-instructions.md
vendored
Normal file
174
.github/copilot-instructions.md
vendored
Normal 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 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<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
2
.vscode/mcp.json
vendored
|
|
@ -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
162
CLAUDE.md
Normal 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
162
GEMINI.md
Normal 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
|
||||
```
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
23
lib/utils/lock_enforcer.dart
Normal file
23
lib/utils/lock_enforcer.dart
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
188
supabase/functions/teams_crud/index.ts
Normal file
188
supabase/functions/teams_crud/index.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
alter table public.duty_schedules
|
||||
add column if not exists reliever_ids uuid[] default '{}'::uuid[];
|
||||
|
|
@ -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();
|
||||
49
supabase/migrations/20260217103000_rls_teams.sql
Normal file
49
supabase/migrations/20260217103000_rls_teams.sql
Normal 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'
|
||||
)
|
||||
);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
105
test/searchable_multi_select_dropdown_test.dart
Normal file
105
test/searchable_multi_select_dropdown_test.dart
Normal 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'}));
|
||||
},
|
||||
);
|
||||
}
|
||||
38
test/services/admin_profile_service_test.dart
Normal file
38
test/services/admin_profile_service_test.dart
Normal 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
139
test/teams_screen_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user