Compare commits

..
1 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 3fbd794f67 fix(auth): correct endpoint path /auth/me → /auth/users/me
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m57s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:40:53 +01:00
53 changed files with 523 additions and 3247 deletions
+4 -5
View File
@@ -13,12 +13,11 @@ pubspec.lock
*.gr.dart
*.mocks.dart
# All generated *.g.dart files (from json_serializable, riverpod, version_builder)
# These are regenerated by build_runner during CI/CD builds
# Keep version.g.dart - it's generated but should be committed
# so CI/CD builds have version info without running the generator
# Other *.g.dart files (from json_serializable, etc.) are ignored
lib/**/*.g.dart
# Generated health.json (regenerated by tool/generate_health_json.dart during build)
web/health.json
!lib/version.g.dart
# IDE
.idea/
+4 -2
View File
@@ -59,8 +59,10 @@ This project uses version-tag-based CI/CD. Releases trigger automated Docker bui
3. Commit changes: `git commit -m "chore: release vX.X.X"`
4. Create git tag: `git tag vX.X.X`
5. Push with tags: `git push origin master --tags`
CI/CD auto-triggers when a tag starting with `v` is pushed.
6. Create release in Gitea UI (git.schweitz.net → Releases → New Release)
* Select the tag
* Add release notes (can copy from CHANGELOG)
* **Publish** the release (this triggers CI/CD)
**What happens on release:**
-243
View File
@@ -7,249 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2026-01-05
### Added
- **URL deep-linking for DataGrids** - State is now reflected in URL query parameters
- `?search=` - DataGrid search query
- `?sort=` - Column ID for sorting
- `?order=desc` - Sort direction
- `?id=` - Opened document ID (proxy hosts page)
- `PageUrlState` utility class (`lib/routing/url_state.dart`) for URL ↔ state serialization
- Browser URL updates via `replaceState` without triggering GoRouter rebuilds
- `id` field added to `DataGridColumn` for unique column identification in URLs
- `FilterPanelSemantics` class for filter panel semantic IDs
- TESTING.md documentation for semantic widgets and automation testing
### Changed
- `idSelector` in DataGridController now returns `String` (was `Object`) for URL compatibility
- All DataGrid pages now support URL deep-linking:
- Containers list (`/control-room/containers`)
- Proxy hosts (`/control-room/proxy-hosts`)
- Users (`/security/users`)
- Groups (`/security/groups`)
- Router passes `GoRouterState` to pages for query parameter access
## [1.2.0] - 2026-01-05
### Added
- **Semantic labels for UI automation** (`lib/core/semantics/`)
- `semantic_ids.dart` - Centralized semantic identifier constants
- `semantic_widget.dart` - Helper widget and extension for adding semantics
- Enables browser automation tools (Puppeteer, WebDriver) via accessibility tree
- Semantic IDs added to:
- Profile dropdown button and menu items (theme options, settings, logout)
- Room navigation tabs (Front Hall, Control Room, Security, Parlor)
- NavPanel items (sidebar navigation)
- `SemanticsBinding.instance.ensureSemantics()` enabled on web builds
## [1.1.16] - 2026-01-05
### Fixed
- Theme toggle causing logout due to AuthInterceptor auto-signout on 401
- Removed aggressive `signOut()` call in `AuthInterceptor.onError`
- 401 errors now propagate to calling code for graceful handling
- Preferences API 401 no longer triggers full logout redirect
## [1.1.15] - 2026-01-05
### Fixed
- Theme toggle causing auth issues due to AuthNotifier auto-dispose
- Applied `@persistentRiverpod` annotation to AuthNotifier
- AuthProvider now persists for app lifetime, preventing rebuild on theme change
## [1.1.14] - 2026-01-04
### Fixed
- Theme toggle causing auth issues due to ThemeProvider auto-dispose
- Added `@persistentRiverpod` annotation for providers that need keepAlive
- ThemeProvider now persists for app lifetime
### Added
- `@persistentRiverpod` annotation in `core/providers/annotations.dart`
- Reusable annotation for providers that should not auto-dispose
- Documented in ARCHITECTURE.md
## [1.1.13] - 2026-01-04
### Added
- Settings page with Appearance, Navigation, and Account sections
- Theme toggle in user profile dropdown (System/Light/Dark)
- Theme syncs with API preferences on login
- Default room preference syncs with backend
### Changed
- Theme changes now persist to both local storage and API
## [1.1.12] - 2026-01-04
### Changed
- Control Room navigation reorganized:
- New "Stack" section with Containers and Proxy Hosts
- New "Data Management" section with PostgreSQL, Redis, Qdrant, Neo4j placeholders
- Removed: Networks, Volumes, Images (Portainer) and Redirections, Streams, Certificates (NPM)
## [1.1.11] - 2026-01-04
### Fixed
- API client providers now use `keepAlive: true` to prevent Ref invalidation
- Fixes "DioException [unknown]: null" error on /security/users and other API pages
- AuthInterceptor's stored Ref was becoming invalid when provider auto-disposed
## [1.1.10] - 2026-01-04
### Changed
- Removed page swipe transitions - all navigation is now instant (NoTransitionPage)
## [1.1.9] - 2026-01-04
### Changed
- Moved health check to `/health` directory - URL is now `/health` instead of `/health.html`
- Enables NPM forward auth path exclusion for health endpoint
## [1.1.8] - 2026-01-04
### Changed
- Dark background (`#1a1a2e`) on web/index.html to prevent white flash during auth redirects
## [1.1.7] - 2026-01-04
### Removed
- Removed `/callback` route from Flutter router - AuthController handles callback in main() before app starts
- Removed `_OidcCallbackPage` widget - no visible auth UI needed
## [1.1.6] - 2026-01-04
### Changed
- **Auth moved to standalone controller** - Handles OIDC completely outside Riverpod
- New `AuthController` runs in `main()` before `runApp()` - avoids provider lifecycle issues
- Handles callback, token exchange, and /auth/sync before app starts
- If auth not ready (redirecting), app doesn't start at all
- `AuthProvider` now just loads stored tokens (no async OIDC logic)
- Fixes "Cannot use Ref after disposed" errors from autoDispose providers
## [1.1.5] - 2026-01-04
### Fixed
- Race condition in OIDC callback: AuthProvider.build() was initiating silent OIDC while the callback page was processing, causing PKCE state to be cleared. Now skips silent OIDC when on `/callback` route.
## [1.1.4] - 2026-01-04
### Fixed
- Silent OIDC fallback: when `prompt=none` fails with `login_required` (no Authentik session), automatically fall back to regular OIDC flow to show login UI
## [1.1.3] - 2026-01-04
### Changed
- **Web auth uses silent OIDC with JWT Bearer tokens**
- Uses `prompt=none` to silently obtain JWT when Authentik session exists (via NPM forward auth)
- Flutter sends Bearer token to core-api instead of relying on forward auth cookies
- Fixes cross-subdomain cookie issues between home.schweitz.net and api.schweitz.net
- Callback now syncs with `/auth/sync` to get user profile and roles from core-api
- API interceptor now adds Bearer token on web (previously skipped)
## [1.1.2] - 2026-01-04
### Changed
- **Web auth simplified**: Skip Flutter OIDC on web - NPM forward auth handles it
- NPM authenticates at proxy level before app loads
- No more redundant OIDC redirect after NPM auth completes
- Fixes "Cannot use Ref after disposed" error from conflicting auth flows
- Mobile still uses Flutter OIDC flow
### Added
- Logout now redirects to Authentik to end SSO session
- Clears local tokens AND invalidates Authentik session
- Uses OIDC end_session_endpoint from discovery document
- Redirects back to app after Authentik logout completes
## [1.1.0] - 2026-01-04
### Changed
- **Dockerfile rebuild fix**: Added `flutter clean` before build to prevent stale cached artifacts
- VERSION build arg added for explicit cache busting
- Reordered build steps: clean → pub get → build_runner → health.json → flutter build
- Ensures deployed app always matches the version in health.json
### Fixed
- Replaced deprecated `dart:html` with `package:web` in iframe_view_web.dart
- Uses `web.HTMLIFrameElement` instead of `html.IFrameElement`
- Fixes deprecation warnings for Flutter 3.x web builds
## [1.0.12] - 2026-01-04
### Changed
- Removed login page - auth now auto-initiates from AppScaffold
- No more redirect to /login, just auto-start OIDC if not authenticated
- Shows loading screen during auth, error screen on failure with retry
- Seamless experience when Authentik session exists
### Removed
- Removed /login route and _LoginPage widget
## [1.0.11] - 2026-01-04
### Changed
- Web auth now extracts user info directly from JWT instead of syncing with core-api
- Eliminates CORS preflight issues with /auth/sync endpoint
- Decodes JWT claims (name, email, groups) client-side
- Bearer token will be used for API authentication
## [1.0.10] - 2026-01-04
### Fixed
- Fixed OIDC callback route being redirected to login before processing
- Moved callback route exception check BEFORE the auth redirect check in router
- This was preventing token exchange from ever happening
- Added favicon.ico to web root for proper browser tab icon display
## [1.0.9] - 2026-01-04
### Fixed
- Fixed OIDC callback Riverpod state modification error
- Deferred callback processing to `addPostFrameCallback` to avoid modifying state during widget build
## [1.0.8] - 2026-01-04
### Changed
- Switched from hash-based URLs (`/#/login`) to path-based URLs (`/login`)
- Required for OIDC callback to work correctly
- Uses conditional import to avoid breaking mobile/desktop builds
## [1.0.7] - 2026-01-04
### Fixed
- Fixed OIDC PKCE state loss across browser redirect
- Code verifier and state now persist in sessionStorage instead of memory
- Prevents "No code verifier" error after Authentik redirect
## [1.0.6] - 2026-01-04
### Fixed
- Fixed version generation in CI/CD builds
- Removed generated files (version.g.dart, health.json) from git tracking
- These files are now regenerated from pubspec.yaml during Docker build
## [1.0.5] - 2026-01-04
### Changed
- **Web authentication now uses OIDC** instead of NPM forward auth
- Added `OidcServiceWeb` for browser redirect-based Authorization Code flow with PKCE
- Added `/callback` route to handle Authentik redirect after login
- Login page now shows "Sign in with Authentik" button for both web and mobile
- Tokens stored in SharedPreferences and synced with core-api via `/auth/sync`
- Added web utility functions (`web_utils.dart`) with conditional imports for non-web platforms
- Added `crypto` and `web` packages for PKCE SHA-256 and browser API access
### Fixed
- Removed cross-origin cookie dependency that caused authentication failures on web
## [1.0.4] - 2026-01-03
### Added
- `health.json` generated at build time with app version info
- `health.html` now displays version, title, and status from health.json
## [1.0.3] - 2026-01-03
### Fixed
+3 -14
View File
@@ -3,29 +3,18 @@ FROM ghcr.io/cirruslabs/flutter:stable AS builder
WORKDIR /app
# VERSION arg busts cache when version changes in pubspec.yaml
# Extract version: docker build --build-arg VERSION=$(grep '^version:' pubspec.yaml | cut -d' ' -f2) .
ARG VERSION=0.0.0
RUN echo "Building version: $VERSION"
# Copy dependency files first for better caching
COPY pubspec.yaml pubspec.lock* ./
COPY pubspec.yaml ./
# Get dependencies
# Get dependencies (generates pubspec.lock)
RUN flutter pub get
# Copy the rest of the application
COPY . .
# Clean any cached build artifacts to ensure fresh build
RUN flutter clean && flutter pub get
# Generate code with build_runner (after clean for fresh generation)
# Generate code with build_runner
RUN dart run build_runner build --delete-conflicting-outputs
# Generate health.json with version info
RUN dart run tool/generate_health_json.dart
# Build for web release with production configuration
# These URLs enable authentication (requiresAuth = true when URL contains schweitz.net)
RUN flutter build web --release \
-61
View File
@@ -20,7 +20,6 @@ lib/
│ ├── auth/ # Authentik OIDC integration
│ ├── config/ # Environment configuration
│ ├── error/ # Error types and handling
│ ├── semantics/ # Semantic IDs for automation
│ └── theme/ # Material 3 theming
├── routing/ # go_router configuration
├── shared/ # Reusable components
@@ -421,41 +420,6 @@ ContainerRepository containerRepository(Ref ref) {
}
```
### Persistent Providers
By default, `@riverpod` generates providers with `isAutoDispose: true`, meaning they dispose when no longer watched. This causes issues for:
- **API clients** with interceptors that store a `Ref`
- **App-level state** like theme, auth, config
- **Providers with listeners** to other providers
Use `@persistentRiverpod` from `core/providers/annotations.dart` for these cases:
```dart
import 'package:tatlock_ui/core/providers/annotations.dart';
// ✅ Correct - persists for app lifetime
@persistentRiverpod
Dio coreApiClient(Ref ref) { ... }
@persistentRiverpod
class ThemeNotifier extends _$ThemeNotifier { ... }
// ❌ Wrong - auto-dispose can invalidate stored Ref
@riverpod
Dio coreApiClient(Ref ref) { ... }
```
**When to use `@persistentRiverpod`:**
| Use Case | Annotation | Example |
|----------|------------|---------|
| API clients with interceptors | `@persistentRiverpod` | `coreApiClient`, `tatlockApiClient` |
| Auth state provider | `@persistentRiverpod` | `AuthNotifier` |
| Theme/config providers | `@persistentRiverpod` | `ThemeNotifier` |
| Feature data providers | `@riverpod` (default) | `ContainersNotifier` |
| UI state providers | `@riverpod` (default) | `SearchFilterNotifier` |
## File Naming Conventions
| Type | Convention | Example |
@@ -545,28 +509,3 @@ import 'package:tatlock_ui/features/containers/domain/entities/container.dart';
// Bad - importing model in presentation
import '../data/models/container_model.dart'; // Don't do this
```
## Semantic Identifiers for Automation
All interactive widgets should have semantic identifiers for UI automation. This enables reliable testing with Puppeteer, Appium, and other automation tools.
### Quick Reference
```dart
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
// Wrap interactive widgets with Semantics
Semantics(
identifier: DataGridSemantics.row(item.id),
label: 'Select ${item.name}',
child: MyRowWidget(item: item),
)
```
### Key Points
1. **Central ID Registry** - All IDs defined in `lib/core/semantics/semantic_ids.dart`
2. **Naming Convention** - `{area}_{component}_{identifier}` (e.g., `dataGrid_row_abc123`)
3. **Web Enabled** - Semantics tree exposed via `SemanticsBinding.instance.ensureSemantics()` in `main.dart`
For complete documentation on semantic patterns, automation queries, and best practices, see **[TESTING.md](./TESTING.md)**.
-368
View File
@@ -1,368 +0,0 @@
# Testing & Automation Guide
This document covers automated testing patterns for Tatlock UI, focusing on semantic identifiers that enable reliable UI automation.
## Overview
Tatlock UI uses Flutter's **Semantics tree** to expose stable identifiers for automated testing. These identifiers are accessible to:
- **Puppeteer** (via Chrome DevTools accessibility API)
- **Appium** (via accessibility labels)
- **WebDriver** (via ARIA attributes)
- **Flutter integration tests**
The semantics system is enabled on web in `main.dart`:
```dart
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
```
## Semantic Identifiers
All semantic IDs are centralized in `lib/core/semantics/semantic_ids.dart`. This provides:
1. **Stable selectors** - IDs don't change with UI refactoring
2. **Type safety** - Compile-time verification of ID usage
3. **Discoverability** - Single source of truth for automation targets
### Available ID Classes
| Class | Purpose | Example IDs |
|-------|---------|-------------|
| `ProfileSemantics` | User profile dropdown | `profile_button`, `profile_menu_settings` |
| `RoomTabSemantics` | Main navigation tabs | `roomTab_frontHall`, `roomTab_controlRoom` |
| `NavSemantics` | Side navigation panel | `nav_panel`, `nav_item_{id}` |
| `DataGridSemantics` | Data tables | `dataGrid_row_{id}`, `dataGrid_search` |
| `DialogSemantics` | Modal dialogs | `dialog_confirm`, `dialog_cancel` |
| `SettingsSemantics` | Settings page | `settings_theme`, `settings_defaultRoom` |
| `StateSemantics` | Loading/error states | `state_auth_loading`, `snackbar_{type}` |
### ID Naming Convention
```
{area}_{component}_{identifier}
```
- **area**: Feature or section (e.g., `profile`, `nav`, `dataGrid`)
- **component**: Widget type (e.g., `menu`, `button`, `row`)
- **identifier**: Specific item (e.g., `light`, `settings`, `selectAll`)
Examples:
- `profile_menu_theme_dark` - Dark theme option in profile menu
- `dataGrid_row_abc123` - Row with ID "abc123" in data grid
- `nav_item_containers` - Containers nav item
## Adding Semantics to Widgets
### Method 1: Direct Semantics Widget
Use Flutter's `Semantics` widget with the `identifier` property:
```dart
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
Semantics(
identifier: ProfileSemantics.button,
label: 'Open profile menu',
button: true,
child: IconButton(
icon: Icon(Icons.person),
onPressed: () => ...,
),
)
```
### Method 2: SemanticWidget Wrapper
Use the convenience wrapper from `lib/core/semantics/semantic_widget.dart`:
```dart
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'package:tatlock_ui/core/semantics/semantic_widget.dart';
SemanticWidget(
id: DataGridSemantics.search,
label: 'Search data grid',
textField: true,
child: TextField(
decoration: InputDecoration(hintText: 'Search...'),
),
)
```
### Method 3: Extension Method
Use the `withSemantics` extension for inline wrapping:
```dart
TextField(
decoration: InputDecoration(hintText: 'Search...'),
).withSemantics(
id: DataGridSemantics.search,
label: 'Search data grid',
)
```
### Dynamic IDs
For lists and grids, use the generator methods:
```dart
// Row in a data grid
Semantics(
identifier: DataGridSemantics.row(item.id), // "dataGrid_row_abc123"
child: DataGridRow(item: item),
)
// Navigation item
Semantics(
identifier: NavSemantics.item(route.id), // "nav_item_containers"
child: NavItem(route: route),
)
// Bulk action button
Semantics(
identifier: DataGridSemantics.bulkAction('delete'), // "dataGrid_bulk_delete"
child: IconButton(icon: Icon(Icons.delete), ...),
)
```
## Querying from Puppeteer
Puppeteer can query semantic identifiers via Chrome's accessibility tree:
```javascript
// Connect to Chrome with DevTools protocol
const browser = await puppeteer.connect({
browserURL: 'http://localhost:9222'
});
const page = await browser.newPage();
// Get accessibility snapshot
const snapshot = await page.accessibility.snapshot({ interestingOnly: false });
// Find element by semantic identifier
function findBySemanticId(node, id) {
if (node.name === id || node.description === id) {
return node;
}
for (const child of node.children || []) {
const found = findBySemanticId(child, id);
if (found) return found;
}
return null;
}
// Example: Find profile button
const profileButton = findBySemanticId(snapshot, 'profile_button');
// Example: Find a specific data grid row
const row = findBySemanticId(snapshot, 'dataGrid_row_abc123');
```
### Using Chrome DevTools MCP
With the Chrome DevTools MCP server, you can query semantics directly:
```javascript
// Take a snapshot (returns accessibility tree)
const snapshot = await mcp__chrome_devtools__take_snapshot();
// Click by semantic ID (uid in snapshot)
await mcp__chrome_devtools__click({ uid: 'profile_button' });
// Fill input by semantic ID
await mcp__chrome_devtools__fill({
uid: 'dataGrid_search',
value: 'my search query'
});
```
## Best Practices
### 1. Add Semantics to Interactive Elements
Every clickable, tappable, or input element should have a semantic identifier:
```dart
// Buttons
Semantics(
identifier: 'myFeature_submit',
button: true,
label: 'Submit form',
child: ElevatedButton(...),
)
// Text fields
Semantics(
identifier: 'myFeature_email',
textField: true,
label: 'Email address',
child: TextField(...),
)
// Checkboxes
Semantics(
identifier: 'myFeature_rememberMe',
checked: isChecked,
label: 'Remember me',
child: Checkbox(...),
)
```
### 2. Use Meaningful Labels
Labels help both accessibility tools and test debugging:
```dart
// Good - descriptive label
Semantics(
identifier: DataGridSemantics.rowAction(item.id, 'delete'),
label: 'Delete ${item.name}',
button: true,
child: ...,
)
// Bad - no context
Semantics(
identifier: 'btn1',
child: ...,
)
```
### 3. Register New IDs Centrally
Always add new semantic IDs to `semantic_ids.dart`:
```dart
/// My new feature IDs.
abstract class MyFeatureSemantics {
static const submitButton = 'myFeature_submit';
static const cancelButton = 'myFeature_cancel';
static const nameField = 'myFeature_name';
/// Generate ID for a list item.
static String item(String id) => 'myFeature_item_$id';
}
```
### 4. Test ID Stability
Semantic IDs should remain stable across releases. When refactoring:
- Keep existing IDs unchanged
- Add deprecation comments if IDs must change
- Update automation tests when IDs change
### 5. Exclude Decorative Elements
Don't add semantic IDs to purely decorative elements:
```dart
// Decorative icon - no semantics needed
Icon(Icons.star, color: Colors.yellow)
// Interactive icon - needs semantics
Semantics(
identifier: 'rating_star_3',
button: true,
label: 'Rate 3 stars',
child: IconButton(
icon: Icon(Icons.star),
onPressed: () => rate(3),
),
)
```
## DataGrid Semantic Patterns
The DataGrid component has comprehensive semantic coverage:
```
dataGrid - The grid container
dataGrid_search - Search input field
dataGrid_search_clear - Clear search button
dataGrid_selectAll - Select all checkbox
dataGrid_header_{columnId} - Column header (sortable)
dataGrid_row_{itemId} - Row container
dataGrid_row_{itemId}_checkbox - Row selection checkbox
dataGrid_row_{itemId}_actions - Row actions menu trigger
dataGrid_row_{itemId}_action_{actionId} - Specific row action
dataGrid_bulk_{actionId} - Bulk action button
dataGrid_bulk_clear - Clear selection button
dataGrid_loading - Loading indicator
dataGrid_empty - Empty state message
dataGrid_error - Error state message
dataGrid_refresh - Refresh button
```
### Example: Automating DataGrid Selection
```javascript
// Select all rows
await click('dataGrid_selectAll');
// Select specific row
await click('dataGrid_row_abc123_checkbox');
// Perform bulk delete
await click('dataGrid_bulk_delete');
// Confirm in dialog
await click('dialog_confirm');
```
## Debugging Semantics
### Flutter DevTools
1. Open Flutter DevTools
2. Go to "Inspector" tab
3. Enable "Semantics" overlay
4. Click widgets to see their semantic properties
### Chrome DevTools
1. Open DevTools (F12)
2. Go to "Accessibility" tab
3. Inspect the accessibility tree
4. Search for semantic identifiers
### Programmatic Inspection
```dart
// In a test, dump the semantics tree
debugDumpSemanticsTree();
// Check if semantics are enabled
print('Semantics enabled: ${SemanticsBinding.instance.semanticsEnabled}');
```
## Integration with URL Routing
For deep-linkable test scenarios, semantic IDs work with URL query parameters:
```
/control-room/containers?selected=abc123
```
Automation can:
1. Navigate to URL with query params
2. Verify selection state via `dataGrid_row_abc123_checkbox` (checked: true)
3. Interact with selected rows via semantic IDs
See [URL Routing](#url-routing) section for query parameter patterns.
## Checklist for New Features
When adding a new feature, ensure semantic coverage:
- [ ] Add semantic ID class to `semantic_ids.dart`
- [ ] Wrap all buttons with `Semantics` + `identifier`
- [ ] Wrap all inputs with `Semantics` + `identifier`
- [ ] Wrap list/grid items with dynamic IDs
- [ ] Add labels for accessibility
- [ ] Test that IDs appear in accessibility snapshot
- [ ] Document IDs in this file if they establish new patterns
+2 -3
View File
@@ -2,7 +2,6 @@ import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_interceptors.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/core/providers/annotations.dart';
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
as platform;
@@ -10,7 +9,7 @@ import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
part 'api_client.g.dart';
/// Provides the Dio instance for Core API.
@persistentRiverpod
@riverpod
Dio coreApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.coreApiUrl,
@@ -34,7 +33,7 @@ Dio coreApiClient(Ref ref) {
}
/// Provides the Dio instance for Tatlock API.
@persistentRiverpod
@riverpod
Dio tatlockApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
+20 -6
View File
@@ -10,7 +10,8 @@ import 'package:tatlock_ui/core/error/app_exception.dart';
/// Adds authentication token to requests.
///
/// - **LAN mode**: Skipped entirely (no auth required)
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
/// - **Web**: Skipped (cookies handle auth via NPM forward auth)
/// - **Mobile**: Adds Bearer token from OIDC authentication
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._ref);
@@ -24,11 +25,17 @@ class AuthInterceptor extends Interceptor {
return;
}
// Add Bearer token for all platforms (web + mobile)
// Skip Bearer token on web - cookies handle auth via NPM forward auth
if (kIsWeb) {
handler.next(options);
return;
}
// Mobile: Add Bearer token from OIDC authentication
final authState = _ref.read(authProvider);
authState.whenData((auth) {
if (auth.isAuthenticated && auth.accessToken != null) {
if (auth.isAuthenticated && auth.accessToken != null && auth.accessToken != 'web-session') {
options.headers['Authorization'] = 'Bearer ${auth.accessToken}';
}
});
@@ -38,9 +45,16 @@ class AuthInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Don't auto-signout on 401 - let calling code handle auth errors gracefully.
// Auto-signout was causing issues (e.g., theme toggle triggering logout when
// preferences API returned 401).
// Skip auth error handling for LAN development
if (!AppConfig.requiresAuth) {
handler.next(err);
return;
}
if (err.response?.statusCode == 401) {
// Token expired - trigger re-authentication
_ref.read(authProvider.notifier).signOut();
}
handler.next(err);
}
}
-288
View File
@@ -1,288 +0,0 @@
import 'dart:convert' show jsonDecode, jsonEncode;
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import 'auth_datasource.dart';
import 'auth_state.dart';
import 'oidc_service_web.dart';
import 'permissions.dart';
import 'user_preferences.dart';
import 'web_utils.dart' as web_utils;
/// Standalone auth controller that handles OIDC flow before app starts.
///
/// This runs outside of Riverpod to avoid lifecycle issues. Call [initialize]
/// in main() before runApp(). The controller will:
/// 1. Handle callback if on /callback route (exchange code, sync, store tokens)
/// 2. Check for valid stored tokens
/// 3. Redirect to silent OIDC if no tokens (app won't continue)
///
/// Once auth is complete, [AuthProvider] can simply read the stored tokens.
class AuthController {
// Storage keys (same as AuthProvider)
static const _accessTokenKey = 'auth_access_token';
static const _refreshTokenKey = 'auth_refresh_token';
static const _expiresAtKey = 'auth_expires_at';
static const _userIdKey = 'auth_user_id';
static const _authentikIdKey = 'auth_authentik_id';
static const _userNameKey = 'auth_user_name';
static const _userEmailKey = 'auth_user_email';
static const _avatarUrlKey = 'auth_avatar_url';
static const _rolesKey = 'auth_roles';
static const _preferencesKey = 'auth_preferences';
/// Initialize auth before app starts.
///
/// Returns true if auth is ready (tokens available).
/// Returns false if redirecting (app should not continue).
/// Throws on error.
static Future<bool> initialize() async {
// Skip auth entirely for LAN mode
if (!AppConfig.requiresAuth) {
developer.log('Auth not required (LAN mode)', name: 'auth_controller');
return true;
}
// Only handle web auth here - mobile uses different flow
if (!kIsWeb) {
developer.log('Non-web platform, skipping controller init', name: 'auth_controller');
return true;
}
final currentUrl = web_utils.getCurrentUrl();
developer.log('Auth controller init, URL: $currentUrl', name: 'auth_controller');
// Check if we're on the callback route
if (currentUrl.contains('/callback')) {
return _handleCallback(currentUrl);
}
// Check for valid stored tokens
final prefs = await SharedPreferences.getInstance();
final accessToken = prefs.getString(_accessTokenKey);
if (accessToken != null) {
final expiresAtMs = prefs.getInt(_expiresAtKey);
final expiresAt = expiresAtMs != null
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
: null;
if (expiresAt == null || expiresAt.isAfter(DateTime.now())) {
developer.log('Valid tokens found', name: 'auth_controller');
return true; // Auth ready
}
developer.log('Tokens expired', name: 'auth_controller');
}
// No valid tokens - initiate silent OIDC
developer.log('No valid tokens, starting silent OIDC', name: 'auth_controller');
await _initiateSilentOidc();
return false; // Redirecting, app should not continue
}
/// Handle the OIDC callback.
static Future<bool> _handleCallback(String url) async {
final uri = Uri.parse(url);
final code = uri.queryParameters['code'];
final state = uri.queryParameters['state'];
final error = uri.queryParameters['error'];
developer.log('Handling callback: code=${code != null}, error=$error', name: 'auth_controller');
// Handle errors
if (error != null) {
if (error == 'login_required') {
// Silent auth failed - no session, start regular OIDC
developer.log('Silent auth failed (login_required), starting regular OIDC', name: 'auth_controller');
await _initiateRegularOidc();
return false;
}
throw Exception('Auth error: $error - ${uri.queryParameters['error_description']}');
}
if (code == null || state == null) {
throw Exception('Invalid callback - missing code or state');
}
// Exchange code for tokens
developer.log('Exchanging code for tokens', name: 'auth_controller');
final oidcService = OidcServiceWeb();
final tokens = await oidcService.exchangeCode(code, state);
// Sync with core-api
developer.log('Syncing with core-api', name: 'auth_controller');
final dio = Dio(BaseOptions(
baseUrl: AppConfig.coreApiUrl,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
final authDatasource = AuthDatasource(dio);
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
developer.log('Synced user: ${syncResponse.name}', name: 'auth_controller');
// Store credentials
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
// Redirect to home (removes callback params from URL)
developer.log('Auth complete, redirecting to home', name: 'auth_controller');
web_utils.redirectTo('/');
return false; // Redirecting
}
/// Initiate silent OIDC (prompt=none).
static Future<void> _initiateSilentOidc() async {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl(silent: true);
developer.log('Redirecting to silent OIDC', name: 'auth_controller');
web_utils.redirectTo(authUrl);
}
/// Initiate regular OIDC (shows login UI).
static Future<void> _initiateRegularOidc() async {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl(silent: false);
developer.log('Redirecting to regular OIDC', name: 'auth_controller');
web_utils.redirectTo(authUrl);
}
/// Store auth data.
static Future<void> _storeAuth({
required String accessToken,
String? refreshToken,
DateTime? expiresAt,
String? userId,
String? authentikId,
String? userName,
String? userEmail,
String? avatarUrl,
List<Role>? roles,
UserPreferences? preferences,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accessTokenKey, accessToken);
if (refreshToken != null) {
await prefs.setString(_refreshTokenKey, refreshToken);
}
if (expiresAt != null) {
await prefs.setInt(_expiresAtKey, expiresAt.millisecondsSinceEpoch);
}
if (userId != null) await prefs.setString(_userIdKey, userId);
if (authentikId != null) await prefs.setString(_authentikIdKey, authentikId);
if (userName != null) await prefs.setString(_userNameKey, userName);
if (userEmail != null) await prefs.setString(_userEmailKey, userEmail);
if (avatarUrl != null) await prefs.setString(_avatarUrlKey, avatarUrl);
if (roles != null) {
final rolesJson = jsonEncode(roles.map((r) => {
'id': r.id,
'name': r.name,
'domain': r.domain.value,
'category': r.category,
'action': r.action.name,
}).toList());
await prefs.setString(_rolesKey, rolesJson);
}
if (preferences != null) {
await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson()));
}
}
/// Load stored auth state (for AuthProvider to use).
static Future<AuthState> loadStoredAuth() async {
try {
final prefs = await SharedPreferences.getInstance();
final accessToken = prefs.getString(_accessTokenKey);
if (accessToken == null) {
return const AuthState();
}
final expiresAtMs = prefs.getInt(_expiresAtKey);
final expiresAt = expiresAtMs != null
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
: null;
final rolesJson = prefs.getString(_rolesKey);
final roles = rolesJson != null ? _parseRoles(rolesJson) : <Role>[];
final prefsJson = prefs.getString(_preferencesKey);
final preferences = prefsJson != null
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
: null;
return AuthState(
isAuthenticated: true,
accessToken: accessToken,
refreshToken: prefs.getString(_refreshTokenKey),
expiresAt: expiresAt,
userId: prefs.getString(_userIdKey),
authentikId: prefs.getString(_authentikIdKey),
userName: prefs.getString(_userNameKey),
userEmail: prefs.getString(_userEmailKey),
avatarUrl: prefs.getString(_avatarUrlKey),
roles: roles,
preferences: preferences,
);
} catch (e) {
developer.log('Failed to load stored auth: $e', name: 'auth_controller');
return const AuthState();
}
}
static List<Role> _parseRoles(String json) {
try {
final list = jsonDecode(json) as List<dynamic>;
return list.map((item) {
final map = item as Map<String, dynamic>;
final domain = Domain.fromString(map['domain'] as String);
final action = Action.fromString(map['action'] as String);
if (domain == null || action == null) return null;
return Role(
id: map['id'] as String,
name: map['name'] as String,
domain: domain,
category: map['category'] as String? ?? 'general',
action: action,
);
}).whereType<Role>().toList();
} catch (e) {
return [];
}
}
/// Clear stored auth (for logout).
static Future<void> clearAuth() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_accessTokenKey);
await prefs.remove(_refreshTokenKey);
await prefs.remove(_expiresAtKey);
await prefs.remove(_userIdKey);
await prefs.remove(_authentikIdKey);
await prefs.remove(_userNameKey);
await prefs.remove(_userEmailKey);
await prefs.remove(_avatarUrlKey);
await prefs.remove(_rolesKey);
await prefs.remove(_preferencesKey);
}
}
+85 -114
View File
@@ -1,31 +1,29 @@
import 'dart:convert' show jsonDecode, jsonEncode;
import 'dart:convert';
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../providers/annotations.dart';
import 'auth_datasource.dart';
import 'auth_state.dart';
import 'oidc_service.dart';
import 'oidc_service_web.dart';
import 'permissions.dart';
import 'user_preferences.dart';
import 'web_utils.dart' as web_utils;
part 'auth_provider.g.dart';
/// Provides authentication state and operations.
///
/// Supports OIDC Authorization Code flow with PKCE on all platforms:
/// - **Web**: Browser redirect to Authentik, callback via /callback route
/// - **Mobile**: flutter_appauth with custom URL scheme
/// Supports two authentication flows:
/// - **Web**: NPM forward auth with Authentik (cookies handled by proxy)
/// - **Mobile**: OIDC Authorization Code flow with flutter_appauth
///
/// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences.
@persistentRiverpod
/// On web, the app calls GET /auth/me to check if user is authenticated
/// via NPM forward auth headers. On mobile, uses OIDC flow then POST /auth/sync.
@riverpod
class AuthNotifier extends _$AuthNotifier {
// Storage keys
static const _accessTokenKey = 'auth_access_token';
@@ -41,12 +39,71 @@ class AuthNotifier extends _$AuthNotifier {
@override
Future<AuthState> build() async {
// AuthController.initialize() in main() handles OIDC flow before app starts.
// By the time we get here, tokens are already stored (or we're in LAN mode).
// Just load the stored auth state.
// On web with auth required, try to get user from NPM forward auth
if (kIsWeb && AppConfig.requiresAuth) {
final webAuth = await _tryWebAuth();
if (webAuth != null) {
return webAuth;
}
// If web auth failed, user needs to refresh to trigger NPM login
developer.log('Web auth not available - user may need to login via proxy', name: 'auth');
return const AuthState();
}
// On mobile or LAN, load from stored auth
return _loadStoredAuth();
}
/// Try to authenticate via NPM forward auth (web only).
///
/// Returns AuthState if authenticated, null if not.
Future<AuthState?> _tryWebAuth() async {
try {
developer.log('Attempting web auth via /auth/me', name: 'auth');
final authDatasource = ref.read(authDatasourceProvider);
final syncResponse = await authDatasource.getCurrentUser();
developer.log(
'Web auth successful: ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
// Store auth data for offline/cached access
await _storeAuth(
accessToken: 'web-session', // Placeholder - web uses cookies
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
return AuthState(
isAuthenticated: true,
accessToken: 'web-session',
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
developer.log('Web auth: not authenticated via proxy', name: 'auth');
return null;
}
developer.log('Web auth error: $e', name: 'auth');
return null;
} catch (e) {
developer.log('Web auth unexpected error: $e', name: 'auth');
return null;
}
}
Future<AuthState> _loadStoredAuth() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -167,7 +224,7 @@ class AuthNotifier extends _$AuthNotifier {
/// Sign in with the appropriate method for the platform.
///
/// - **Web**: Redirects to Authentik for OIDC authentication
/// - **Web**: Triggers page reload to go through NPM forward auth
/// - **Mobile**: Opens Authentik login via OIDC, then syncs with core-api
Future<void> signIn() async {
if (!AppConfig.requiresAuth) {
@@ -177,25 +234,25 @@ class AuthNotifier extends _$AuthNotifier {
return;
}
// Web: Use OIDC flow with browser redirect
// On web, auth is handled by NPM forward auth
// User needs to access via the authenticated proxy URL
if (kIsWeb) {
developer.log('Web sign-in: starting OIDC flow', name: 'auth');
developer.log('Web sign-in: user should access via authenticated proxy', name: 'auth');
// Try to refresh auth state from /auth/me
state = const AsyncLoading();
try {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl();
developer.log('Redirecting to: $authUrl', name: 'auth');
web_utils.redirectTo(authUrl);
// Browser will redirect, so we don't update state here
} catch (e, stack) {
developer.log('Failed to start OIDC flow: $e', name: 'auth');
state = AsyncError(e, stack);
final webAuth = await _tryWebAuth();
if (webAuth != null) {
state = AsyncData(webAuth);
} else {
state = AsyncError(
Exception('Not authenticated - please access via the authenticated URL'),
StackTrace.current,
);
}
return;
}
// Mobile: Use OIDC flow with flutter_appauth
// Mobile: Use OIDC flow
state = const AsyncLoading();
try {
@@ -250,97 +307,11 @@ class AuthNotifier extends _$AuthNotifier {
}
}
/// Handle OIDC callback after Authentik redirects back (web only).
///
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter for CSRF verification.
Future<void> handleOidcCallback(String code, String callbackState) async {
if (!kIsWeb) {
developer.log('handleOidcCallback called on non-web platform', name: 'auth');
return;
}
developer.log('Handling OIDC callback', name: 'auth');
state = const AsyncLoading();
try {
// Step 1: Exchange code for tokens
final oidcService = OidcServiceWeb();
final tokens = await oidcService.exchangeCode(code, callbackState);
// Step 2: Sync with core-api to get user profile and roles
developer.log('Syncing with core-api', name: 'auth');
final authDatasource = ref.read(authDatasourceProvider);
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
developer.log(
'Synced user: ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
// Step 3: Store credentials and user data from sync response
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
));
developer.log('Authenticated as ${syncResponse.name}', name: 'auth');
// Clean up the URL by removing the query parameters
web_utils.replaceUrl('/');
} on OidcException catch (e) {
developer.log('OIDC callback failed: $e', name: 'auth');
state = AsyncError(e, StackTrace.current);
} catch (e, stack) {
developer.log('Callback handling failed: $e', name: 'auth');
state = AsyncError(e, stack);
}
}
/// Sign out and clear stored credentials.
///
/// On web, also redirects to Authentik's logout endpoint to end the SSO session.
Future<void> signOut() async {
// Clear local storage first
await _clearStoredAuth();
state = const AsyncData(AuthState());
developer.log('Signed out locally', name: 'auth');
// On web, redirect to Authentik logout to end SSO session
if (kIsWeb && AppConfig.requiresAuth) {
try {
final oidcService = OidcServiceWeb();
final logoutUrl = await oidcService.getLogoutUrl();
developer.log('Redirecting to Authentik logout', name: 'auth');
web_utils.redirectTo(logoutUrl);
} catch (e) {
developer.log('Failed to get logout URL: $e', name: 'auth');
// Local logout already done, just reload to trigger re-auth
web_utils.redirectTo('/');
}
}
developer.log('Signed out', name: 'auth');
}
/// Update user preferences.
-245
View File
@@ -1,245 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../config/app_config.dart';
import 'oidc_service.dart';
import 'web_utils.dart' as web_utils;
/// Web implementation of OIDC service using browser redirect flow.
///
/// Uses Authorization Code flow with PKCE for secure authentication.
/// On web, we can't use flutter_appauth, so we implement the flow manually
/// using browser redirects and URL parsing.
class OidcServiceWeb implements OidcService {
OidcServiceWeb({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
/// OIDC scopes to request.
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
/// Redirect URI for web.
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
// SessionStorage keys for PKCE state (persists across redirect)
static const _codeVerifierKey = 'oidc_code_verifier';
static const _stateKey = 'oidc_state';
/// Get the authorization URL to redirect the browser to.
///
/// Returns a URL that the browser should navigate to for authentication.
/// The [codeVerifier] and [state] are stored for later verification.
///
/// If [silent] is true, adds `prompt=none` to skip login UI.
/// This is used when the user already has an Authentik session (via NPM).
/// Authentik will instantly redirect back with a code, or return an error
/// if there's no valid session.
Future<String> getAuthorizationUrl({bool silent = false}) async {
// Fetch OIDC discovery document
final discovery = await _fetchDiscovery();
final authEndpoint = discovery['authorization_endpoint'] as String;
// Generate PKCE code verifier and challenge
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
// Generate state for CSRF protection
final state = _generateRandomString(32);
// Store PKCE state in sessionStorage (persists across redirect)
web_utils.setSessionStorage(_codeVerifierKey, codeVerifier);
web_utils.setSessionStorage(_stateKey, state);
// Build authorization URL
final params = {
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'response_type': 'code',
'scope': _scopes.join(' '),
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': state,
if (silent) 'prompt': 'none', // Silent auth - no UI, instant redirect
};
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
developer.log('Authorization URL (silent=$silent): $uri', name: 'oidc_web');
return uri.toString();
}
/// Exchange authorization code for tokens.
///
/// Call this after the browser redirects back with the authorization code.
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter from the callback URL (verified for CSRF).
Future<OidcTokens> exchangeCode(String code, String state) async {
// Retrieve PKCE state from sessionStorage
final storedState = web_utils.getSessionStorage(_stateKey);
final codeVerifier = web_utils.getSessionStorage(_codeVerifierKey);
developer.log('Stored state: $storedState, received state: $state', name: 'oidc_web');
developer.log('Code verifier present: ${codeVerifier != null}', name: 'oidc_web');
// Verify state matches
if (storedState == null || state != storedState) {
_clearPkceState();
throw OidcException('State mismatch - possible CSRF attack');
}
if (codeVerifier == null) {
_clearPkceState();
throw OidcException('No code verifier - flow not started properly');
}
try {
// Fetch token endpoint from discovery
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
developer.log('Exchanging code at: $tokenEndpoint', name: 'oidc_web');
// Exchange code for tokens
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'authorization_code',
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'code': code,
'code_verifier': codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
developer.log('Token exchange successful', name: 'oidc_web');
// Clear stored PKCE state
_clearPkceState();
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String?,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
developer.log('Token exchange failed: $e', name: 'oidc_web');
_clearPkceState();
throw OidcException('Token exchange failed: ${e.message}');
}
}
/// Clear PKCE state from sessionStorage.
void _clearPkceState() {
web_utils.removeSessionStorage(_codeVerifierKey);
web_utils.removeSessionStorage(_stateKey);
}
/// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead.
@override
Future<OidcTokens> signIn() async {
throw OidcException(
'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.',
);
}
/// Refresh the access token using a refresh token.
@override
Future<OidcTokens> refreshToken(String refreshToken) async {
try {
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'refresh_token',
'client_id': AppConfig.authClientId,
'refresh_token': refreshToken,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String? ?? refreshToken,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
throw OidcException('Token refresh failed: ${e.message}');
}
}
/// Fetch OIDC discovery document.
Future<Map<String, dynamic>> _fetchDiscovery() async {
final response = await _dio.get<Map<String, dynamic>>(
AppConfig.authDiscoveryUrl,
);
return response.data!;
}
/// Generate a random code verifier for PKCE.
String _generateCodeVerifier() {
return _generateRandomString(64);
}
/// Generate code challenge from verifier using S256.
String _generateCodeChallenge(String verifier) {
final bytes = utf8.encode(verifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Generate a random string of given length.
String _generateRandomString(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (_) => chars[random.nextInt(chars.length)])
.join();
}
/// Get the logout URL to redirect the browser to for SSO logout.
///
/// [idToken] is optional but recommended for logout verification.
/// After logout, Authentik redirects back to [postLogoutRedirectUri].
Future<String> getLogoutUrl({String? idToken}) async {
final discovery = await _fetchDiscovery();
final endSessionEndpoint = discovery['end_session_endpoint'] as String?;
if (endSessionEndpoint == null) {
// Fallback: just redirect to home, local state already cleared
developer.log('No end_session_endpoint in discovery', name: 'oidc_web');
return AppConfig.webBaseUrl;
}
final params = <String, String>{
'post_logout_redirect_uri': AppConfig.webBaseUrl,
};
if (idToken != null) {
params['id_token_hint'] = idToken;
}
final uri = Uri.parse(endSessionEndpoint).replace(queryParameters: params);
developer.log('Logout URL: $uri', name: 'oidc_web');
return uri.toString();
}
}
-7
View File
@@ -1,7 +0,0 @@
/// Web utilities with conditional imports.
///
/// Uses stub implementation on non-web platforms.
library;
export 'web_utils_stub.dart'
if (dart.library.js_interop) 'web_utils_web.dart';
-32
View File
@@ -1,32 +0,0 @@
/// Stub for non-web platforms.
library;
/// Redirect to a URL (no-op on non-web).
void redirectTo(String url) {
throw UnsupportedError('redirectTo is only supported on web');
}
/// Get current URL (no-op on non-web).
String getCurrentUrl() {
throw UnsupportedError('getCurrentUrl is only supported on web');
}
/// Replace current URL without navigation (no-op on non-web).
void replaceUrl(String url) {
throw UnsupportedError('replaceUrl is only supported on web');
}
/// Store a value in sessionStorage (no-op on non-web).
void setSessionStorage(String key, String value) {
throw UnsupportedError('setSessionStorage is only supported on web');
}
/// Get a value from sessionStorage (no-op on non-web).
String? getSessionStorage(String key) {
throw UnsupportedError('getSessionStorage is only supported on web');
}
/// Remove a value from sessionStorage (no-op on non-web).
void removeSessionStorage(String key) {
throw UnsupportedError('removeSessionStorage is only supported on web');
}
-34
View File
@@ -1,34 +0,0 @@
/// Web-specific utilities for browser operations.
library;
import 'package:web/web.dart' as web;
/// Redirect the browser to a URL.
void redirectTo(String url) {
web.window.location.href = url;
}
/// Get the current browser URL.
String getCurrentUrl() {
return web.window.location.href;
}
/// Replace the current URL in history without navigation.
void replaceUrl(String url) {
web.window.history.replaceState(null, '', url);
}
/// Store a value in sessionStorage.
void setSessionStorage(String key, String value) {
web.window.sessionStorage.setItem(key, value);
}
/// Get a value from sessionStorage.
String? getSessionStorage(String key) {
return web.window.sessionStorage.getItem(key);
}
/// Remove a value from sessionStorage.
void removeSessionStorage(String key) {
web.window.sessionStorage.removeItem(key);
}
+1 -7
View File
@@ -45,18 +45,12 @@ class AppConfig {
defaultValue: 'tatlock-ui',
);
/// Authentik redirect URI scheme (for mobile/native)
/// Authentik redirect URI scheme
static const authRedirectScheme = String.fromEnvironment(
'AUTH_REDIRECT_SCHEME',
defaultValue: 'net.schweitz.tatlock',
);
/// Web app base URL (for OIDC redirect URI on web)
static const webBaseUrl = String.fromEnvironment(
'WEB_BASE_URL',
defaultValue: 'https://home.schweitz.net',
);
/// Whether running in debug mode
static const isDebug = bool.fromEnvironment('DEBUG', defaultValue: false);
-5
View File
@@ -1,5 +0,0 @@
/// URL strategy with conditional imports for web/non-web platforms.
library;
export 'url_strategy_stub.dart'
if (dart.library.js_interop) 'url_strategy_web.dart';
-4
View File
@@ -1,4 +0,0 @@
/// Stub for non-web platforms - does nothing.
void configureUrlStrategy() {
// No-op on mobile/desktop
}
-10
View File
@@ -1,10 +0,0 @@
/// Web-specific URL strategy configuration.
library;
import 'package:flutter_web_plugins/url_strategy.dart';
void configureUrlStrategy() {
// Use path-based URLs instead of hash-based (e.g., /login instead of /#/login)
// Required for OIDC callback to work properly
usePathUrlStrategy();
}
-15
View File
@@ -1,15 +0,0 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
/// Riverpod annotation for providers that should persist for the app lifetime.
///
/// Use this instead of `@riverpod` when:
/// - The provider holds app-level state (theme, auth, config)
/// - The provider stores a Ref that must remain valid (API clients with interceptors)
/// - Disposing would cause flickering or re-initialization issues
///
/// Example:
/// ```dart
/// @persistentRiverpod
/// class ThemeNotifier extends _$ThemeNotifier { ... }
/// ```
const persistentRiverpod = Riverpod(keepAlive: true);
-125
View File
@@ -1,125 +0,0 @@
/// Semantic identifiers for UI automation and accessibility.
///
/// These IDs are exposed via Flutter's Semantics tree, making widgets
/// discoverable by automation tools (Appium, WebDriver, Puppeteer with
/// accessibility enabled).
///
/// Naming convention: `{area}_{component}_{identifier}`
/// - area: room or feature area (e.g., profile, nav, dataGrid)
/// - component: widget type (e.g., menu, button, row)
/// - identifier: specific item (e.g., light, containers, selectAll)
library;
/// Profile dropdown menu IDs.
abstract class ProfileSemantics {
static const button = 'profile_button';
static const menu = 'profile_menu';
static const settings = 'profile_menu_settings';
static const themeSystem = 'profile_menu_theme_system';
static const themeLight = 'profile_menu_theme_light';
static const themeDark = 'profile_menu_theme_dark';
static const logout = 'profile_menu_logout';
}
/// Room tab navigation IDs.
abstract class RoomTabSemantics {
static const frontHall = 'roomTab_frontHall';
static const controlRoom = 'roomTab_controlRoom';
static const security = 'roomTab_security';
static const parlor = 'roomTab_parlor';
/// Get semantic ID for room index.
static String forIndex(int index) => switch (index) {
0 => frontHall,
1 => controlRoom,
2 => security,
3 => parlor,
_ => 'roomTab_$index',
};
}
/// Navigation panel IDs.
abstract class NavSemantics {
static const panel = 'nav_panel';
static const refresh = 'nav_refresh';
/// Generate ID for a nav item.
static String item(String id) => 'nav_item_$id';
/// Generate ID for a nav section.
static String section(String id) => 'nav_section_$id';
}
/// DataGrid component IDs.
abstract class DataGridSemantics {
static const grid = 'dataGrid';
static const search = 'dataGrid_search';
static const searchClear = 'dataGrid_search_clear';
static const selectAll = 'dataGrid_selectAll';
static const loading = 'dataGrid_loading';
static const empty = 'dataGrid_empty';
static const error = 'dataGrid_error';
static const refresh = 'dataGrid_refresh';
/// Generate ID for a column header.
static String header(String columnId) => 'dataGrid_header_$columnId';
/// Generate ID for a row.
static String row(String itemId) => 'dataGrid_row_$itemId';
/// Generate ID for a row checkbox.
static String rowCheckbox(String itemId) => 'dataGrid_row_${itemId}_checkbox';
/// Generate ID for a row actions menu.
static String rowActions(String itemId) => 'dataGrid_row_${itemId}_actions';
/// Generate ID for a row action.
static String rowAction(String itemId, String actionId) =>
'dataGrid_row_${itemId}_action_$actionId';
/// Generate ID for a bulk action button.
static String bulkAction(String actionId) => 'dataGrid_bulk_$actionId';
static const bulkClear = 'dataGrid_bulk_clear';
}
/// Dialog/modal IDs.
abstract class DialogSemantics {
static const confirm = 'dialog_confirm';
static const cancel = 'dialog_cancel';
static const close = 'dialog_close';
/// Generate ID for a named dialog.
static String named(String name) => 'dialog_$name';
/// Generate ID for a dialog action button.
static String action(String dialogName, String actionId) =>
'dialog_${dialogName}_$actionId';
}
/// Settings page IDs.
abstract class SettingsSemantics {
static const themeDropdown = 'settings_theme';
static const defaultRoomDropdown = 'settings_defaultRoom';
}
/// Filter panel IDs.
abstract class FilterPanelSemantics {
static const panel = 'filterPanel';
static const search = 'filterPanel_search';
static const searchClear = 'filterPanel_search_clear';
static const refresh = 'filterPanel_refresh';
/// Generate ID for a filter item.
static String item(String id) => 'filterPanel_item_$id';
}
/// Loading/state indicator IDs.
abstract class StateSemantics {
static const authLoading = 'state_auth_loading';
static const authError = 'state_auth_error';
static const pageLoading = 'state_page_loading';
/// Generate ID for a snackbar.
static String snackbar(String type) => 'snackbar_$type';
}
-118
View File
@@ -1,118 +0,0 @@
import 'package:flutter/material.dart';
/// Wraps a widget with semantic information for accessibility and automation.
///
/// Usage:
/// ```dart
/// SemanticWidget(
/// id: ProfileSemantics.button,
/// label: 'Open profile menu',
/// child: IconButton(...),
/// )
/// ```
///
/// For buttons, use `button: true`. For other interactive elements,
/// set the appropriate semantic properties.
class SemanticWidget extends StatelessWidget {
const SemanticWidget({
super.key,
required this.id,
required this.child,
this.label,
this.hint,
this.button = false,
this.link = false,
this.header = false,
this.textField = false,
this.enabled = true,
this.selected,
this.checked,
this.value,
this.excludeSemantics = false,
});
/// Unique identifier for this widget, exposed via [SemanticsProperties.identifier].
final String id;
/// The widget to wrap.
final Widget child;
/// Accessibility label describing the widget.
final String? label;
/// Hint text for screen readers.
final String? hint;
/// Whether this widget represents a button.
final bool button;
/// Whether this widget represents a link.
final bool link;
/// Whether this widget represents a header.
final bool header;
/// Whether this widget represents a text field.
final bool textField;
/// Whether the widget is enabled.
final bool enabled;
/// Whether the widget is selected (for toggle buttons, tabs).
final bool? selected;
/// Whether the widget is checked (for checkboxes).
final bool? checked;
/// Current value (for sliders, progress indicators).
final String? value;
/// Whether to exclude child semantics.
final bool excludeSemantics;
@override
Widget build(BuildContext context) {
return Semantics(
identifier: id,
label: label,
hint: hint,
button: button,
link: link,
header: header,
textField: textField,
enabled: enabled,
selected: selected,
checked: checked,
value: value,
excludeSemantics: excludeSemantics,
child: child,
);
}
}
/// Extension to easily wrap any widget with semantic info.
extension SemanticExtension on Widget {
/// Wraps this widget with a semantic identifier.
Widget withSemantics({
required String id,
String? label,
String? hint,
bool button = false,
bool link = false,
bool enabled = true,
bool? selected,
bool? checked,
}) {
return SemanticWidget(
id: id,
label: label,
hint: hint,
button: button,
link: link,
enabled: enabled,
selected: selected,
checked: checked,
child: this,
);
}
}
+1 -43
View File
@@ -1,10 +1,6 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/providers/annotations.dart';
part 'theme_provider.g.dart';
@@ -21,21 +17,13 @@ enum ThemeSetting {
}
/// Provider for theme setting state.
///
/// Syncs with API preferences when user is authenticated. On login, the theme
/// from API preferences takes precedence over local storage.
@persistentRiverpod
@riverpod
class ThemeNotifier extends _$ThemeNotifier {
static const _prefsKey = 'theme_setting';
@override
ThemeSetting build() {
// Load local setting first for immediate UI
_loadSavedSetting();
// Listen for auth state changes to sync from API preferences
_syncFromAuthPreferences();
return ThemeSetting.system;
}
@@ -51,36 +39,6 @@ class ThemeNotifier extends _$ThemeNotifier {
}
}
/// Listen to auth state and sync theme from API preferences.
void _syncFromAuthPreferences() {
ref.listen(authProvider, (_, next) {
next.whenData((auth) {
final apiTheme = auth.preferences?.theme;
if (apiTheme != null && apiTheme.isNotEmpty) {
try {
final themeSetting = ThemeSetting.values.byName(apiTheme);
if (themeSetting != state) {
developer.log(
'Syncing theme from API: $apiTheme',
name: 'theme',
);
state = themeSetting;
// Also persist to local storage for offline use
_saveToLocalStorage(themeSetting);
}
} catch (_) {
// Invalid theme value from API, keep current
}
}
});
});
}
Future<void> _saveToLocalStorage(ThemeSetting setting) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, setting.name);
}
/// Update theme setting and persist to storage.
Future<void> setSetting(ThemeSetting setting) async {
state = setting;
@@ -1,13 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart' hide Container;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/providers/containers_provider.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_logs_viewer.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -105,10 +101,7 @@ class ContainerPort {
/// Page displaying the list of containers using DataGrid.
class ContainersListPage extends ConsumerStatefulWidget {
const ContainersListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
const ContainersListPage({super.key});
@override
ConsumerState<ContainersListPage> createState() => _ContainersListPageState();
@@ -117,18 +110,10 @@ class ContainersListPage extends ConsumerStatefulWidget {
class _ContainersListPageState extends ConsumerState<ContainersListPage> {
late final StateNotifierProvider<DataGridController<ContainerData>,
DataGridState<ContainerData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<ContainerData>(
dio: dio,
@@ -136,68 +121,17 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
fromJson: ContainerData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<ContainerData>(
source: source,
config: _buildConfig(),
idSelector: (c) => c.id,
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final params = PageUrlState(
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
DataGridConfig<ContainerData> _buildConfig() {
return DataGridConfig<ContainerData>(
columns: [
DataGridColumn<ContainerData>(
id: 'container',
header: 'Container',
valueBuilder: (c) => '${c.name} ${c.image}',
sortable: true,
@@ -206,14 +140,12 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
cellBuilder: (context, c) => _ContainerCell(container: c),
),
DataGridColumn<ContainerData>(
id: 'ports',
header: 'Ports',
valueBuilder: (c) => c.ports.map((p) => p.formatted).join(', '),
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, c) => _PortsCell(ports: c.ports),
),
DataGridColumn<ContainerData>(
id: 'status',
header: 'Status',
valueBuilder: (c) => c.displayStatus,
width: const DataGridColumnWidth.fixed(160),
@@ -279,15 +211,6 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
// Listen for container action results to show snackbars
ref.listen<AsyncValue<void>>(containerActionsProvider, (previous, next) {
if (previous?.isLoading == true && !next.isLoading) {
@@ -1,12 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_host_page.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -37,10 +33,7 @@ class DomainData {
/// Page displaying the list of proxy hosts (domains) from NPM.
class ProxyHostsPage extends ConsumerStatefulWidget {
const ProxyHostsPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
const ProxyHostsPage({super.key});
@override
ConsumerState<ProxyHostsPage> createState() => _ProxyHostsPageState();
@@ -49,18 +42,10 @@ class ProxyHostsPage extends ConsumerStatefulWidget {
class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
late final StateNotifierProvider<DataGridController<DomainData>,
DataGridState<DomainData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<DomainData>(
dio: dio,
@@ -68,80 +53,17 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
fromJson: DomainData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<DomainData>(
source: source,
config: _buildConfig(),
idSelector: (d) => d.proxyHostId.toString(),
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
// Open document from URL if id present
if (_urlState.id != null) {
final id = int.tryParse(_urlState.id!);
if (id != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(selectedProxyHostProvider.notifier).select(id);
});
}
}
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final selectedId = ref.read(selectedProxyHostProvider);
final params = PageUrlState(
id: selectedId?.toString(),
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
DataGridConfig<DomainData> _buildConfig() {
return DataGridConfig<DomainData>(
columns: [
DataGridColumn<DomainData>(
id: 'domain',
header: 'Domain',
valueBuilder: (d) => d.domain,
sortable: true,
@@ -150,14 +72,12 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
cellBuilder: (context, d) => _DomainCell(domain: d),
),
DataGridColumn<DomainData>(
id: 'service',
header: 'Service',
valueBuilder: (d) => d.service,
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, d) => _ServiceCell(service: d.service),
),
DataGridColumn<DomainData>(
id: 'ssl',
header: 'SSL',
valueBuilder: (d) => d.sslEnabled ? 'Enabled' : 'Disabled',
width: const DataGridColumnWidth.fixed(100),
@@ -181,8 +101,6 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
void _viewDomain(DomainData domain) {
ref.read(selectedProxyHostProvider.notifier).select(domain.proxyHostId);
// Navigate with id to enable back button
context.go('/control-room/proxy-hosts?id=${domain.proxyHostId}');
}
void _createNew() {
@@ -194,15 +112,6 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
final selectedId = ref.watch(selectedProxyHostProvider);
final isCreating = ref.watch(creatingProxyHostProvider);
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
// Show create page if creating new
if (isCreating) {
return ProxyHostPage.create(
@@ -219,8 +128,6 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
proxyHostId: selectedId,
onClose: () {
ref.read(selectedProxyHostProvider.notifier).clear();
// Clear id from URL
context.go('/control-room/proxy-hosts');
ref.read(_gridProvider.notifier).refresh();
},
);
@@ -17,15 +17,11 @@ class ControlRoomPage extends ConsumerWidget {
const ControlRoomPage({
super.key,
this.nav = ControlRoomNav.containers,
this.routerState,
});
/// The current nav item to display.
final ControlRoomNav nav;
/// Router state for URL deep-linking (query params).
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
@@ -57,7 +53,7 @@ class ControlRoomPage extends ConsumerWidget {
),
// Section content
Expanded(
child: _SectionContent(nav: nav, routerState: routerState),
child: _SectionContent(nav: nav),
),
],
),
@@ -67,18 +63,50 @@ class ControlRoomPage extends ConsumerWidget {
/// Renders content for the selected nav item.
class _SectionContent extends ConsumerWidget {
const _SectionContent({required this.nav, this.routerState});
const _SectionContent({required this.nav});
final ControlRoomNav nav;
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
return switch (nav) {
ControlRoomNav.containers => _ContainersSection(routerState: routerState),
ControlRoomNav.proxyHosts => ProxyHostsPage(routerState: routerState),
_ => _PlaceholderSection(nav: nav),
};
switch (nav) {
case ControlRoomNav.containers:
return const _ContainersSection();
case ControlRoomNav.proxyHosts:
return const ProxyHostsPage();
default:
return _PlaceholderSection(nav: nav);
}
}
}
/// Containers section with optional stack filter.
class _ContainersSection extends ConsumerWidget {
const _ContainersSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedStack = ref.watch(selectedStackProvider);
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
// Stacks filter panel
const _StacksFilterPanel(),
// Divider
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
// Main content - containers list or stack detail
Expanded(
child: selectedStack == null
? const ContainersListPage()
: StackDetailPage(stackId: selectedStack),
),
],
);
}
}
@@ -122,43 +150,9 @@ class _PlaceholderSection extends StatelessWidget {
}
}
/// Containers section with optional stack filter.
class _ContainersSection extends ConsumerWidget {
const _ContainersSection({this.routerState});
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedStack = ref.watch(selectedStackProvider);
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
// Stacks filter panel
_StacksFilterPanel(routerState: routerState),
// Divider
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
// Main content - containers list or stack detail
Expanded(
child: selectedStack == null
? ContainersListPage(routerState: routerState)
: StackDetailPage(stackId: selectedStack),
),
],
);
}
}
/// Stacks filter panel for Containers section (includes "All Containers" option).
class _StacksFilterPanel extends ConsumerWidget {
const _StacksFilterPanel({this.routerState});
final GoRouterState? routerState;
const _StacksFilterPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -216,7 +210,6 @@ class _StacksFilterPanel extends ConsumerWidget {
data: (stacks) => _StacksList(
stacks: stacks,
selectedStackId: selectedStack,
routerState: routerState,
onStackSelected: (id) =>
ref.read(selectedStackProvider.notifier).select(id),
onStackAction: (id, action) =>
@@ -291,14 +284,12 @@ class _StacksList extends StatefulWidget {
required this.selectedStackId,
required this.onStackSelected,
required this.onStackAction,
this.routerState,
});
final List<Stack> stacks;
final String? selectedStackId;
final void Function(String) onStackSelected;
final void Function(String, String) onStackAction;
final GoRouterState? routerState;
@override
State<_StacksList> createState() => _StacksListState();
+19 -20
View File
@@ -1,32 +1,35 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Control Room.
abstract class ControlRoomRoutes {
static const base = '/control-room';
// Stack
// Portainer
static const containers = '/control-room/containers';
static const networks = '/control-room/networks';
static const volumes = '/control-room/volumes';
static const images = '/control-room/images';
// NPM
static const proxyHosts = '/control-room/proxy-hosts';
// Data Management
static const postgres = '/control-room/postgres';
static const redis = '/control-room/redis';
static const qdrant = '/control-room/qdrant';
static const neo4j = '/control-room/neo4j';
static const redirections = '/control-room/redirections';
static const streams = '/control-room/streams';
static const certificates = '/control-room/certificates';
}
/// Control Room navigation items with section grouping.
enum ControlRoomNav {
// Stack Management section - Docker containers and reverse proxy
containers('containers', 'Containers', Icons.dns, 'Stack Management'),
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'Stack Management'),
// Data Management section - Database browsers
postgres('postgres', 'PostgreSQL', Icons.table_chart, 'Data Management'),
redis('redis', 'Redis', Icons.memory, 'Data Management'),
qdrant('qdrant', 'Qdrant', Icons.scatter_plot, 'Data Management'),
neo4j('neo4j', 'Neo4j', Icons.hub, 'Data Management');
// Portainer section
containers('containers', 'Containers', Icons.dns, 'Portainer'),
networks('networks', 'Networks', Icons.hub, 'Portainer'),
volumes('volumes', 'Volumes', Icons.storage, 'Portainer'),
images('images', 'Images', Icons.photo_library, 'Portainer'),
// NPM section
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'NPM'),
redirections('redirections', 'Redirections', Icons.alt_route, 'NPM'),
streams('streams', 'Streams', Icons.stream, 'NPM'),
certificates('certificates', 'SSL Certificates', Icons.verified_user, 'NPM');
const ControlRoomNav(this.id, this.label, this.icon, this.section);
@@ -63,11 +66,7 @@ List<RouteBase> controlRoomRoutes() {
GoRoute(
path: nav.path,
name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}',
pageBuilder: (context, state) => noTransitionPage(
context,
state,
ControlRoomPage(nav: nav, routerState: state),
),
builder: (context, state) => ControlRoomPage(nav: nav),
),
];
}
@@ -1,8 +1,8 @@
import 'dart:html' as html;
import 'dart:ui_web' as ui_web;
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:web/web.dart' as web;
/// Embedded iframe view for displaying external content (web only).
///
@@ -31,7 +31,7 @@ class IframeView extends StatefulWidget {
class _IframeViewState extends State<IframeView> {
late final String _viewType;
late web.HTMLIFrameElement _iframe;
late html.IFrameElement _iframe;
bool _isLoading = true;
@override
@@ -42,18 +42,17 @@ class _IframeViewState extends State<IframeView> {
}
void _createIframe() {
_iframe = web.document.createElement('iframe') as web.HTMLIFrameElement
_iframe = html.IFrameElement()
..src = widget.url
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..allow = 'fullscreen';
_iframe.onLoad.listen((_) {
if (mounted) {
setState(() => _isLoading = false);
}
});
..allow = 'fullscreen'
..onLoad.listen((_) {
if (mounted) {
setState(() => _isLoading = false);
}
});
// Register the view factory
ui_web.platformViewRegistry.registerViewFactory(
@@ -1,10 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -35,10 +31,7 @@ class GroupData {
/// Groups list page using the shared DataGrid component.
class GroupsListPage extends ConsumerStatefulWidget {
const GroupsListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
const GroupsListPage({super.key});
@override
ConsumerState<GroupsListPage> createState() => _GroupsListPageState();
@@ -47,19 +40,11 @@ class GroupsListPage extends ConsumerStatefulWidget {
class _GroupsListPageState extends ConsumerState<GroupsListPage> {
late final StateNotifierProvider<DataGridController<GroupData>,
DataGridState<GroupData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
bool _isSyncing = false;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
// Create provider in initState to ensure stable reference
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<GroupData>(
@@ -68,63 +53,13 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
fromJson: GroupData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<GroupData>(
source: source,
config: _buildConfig(),
idSelector: (g) => g.id,
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final params = PageUrlState(
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
Future<void> _syncFromAuthentik() async {
if (_isSyncing) return;
setState(() => _isSyncing = true);
@@ -161,7 +96,6 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
return DataGridConfig<GroupData>(
columns: [
DataGridColumn<GroupData>(
id: 'name',
header: 'Name',
valueBuilder: (g) => g.name,
sortable: true,
@@ -169,21 +103,18 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
width: const DataGridColumnWidth.flex(2),
),
DataGridColumn<GroupData>(
id: 'members',
header: 'Members',
valueBuilder: (g) => g.memberCount.toString(),
width: const DataGridColumnWidth.fixed(100),
alignment: DataGridColumnAlignment.end,
),
DataGridColumn<GroupData>(
id: 'type',
header: 'Type',
valueBuilder: (g) => g.isSuperuser ? 'Superuser' : 'Standard',
cellBuilder: (context, g) => _GroupTypeBadge(isSuperuser: g.isSuperuser),
width: const DataGridColumnWidth.fixed(120),
),
DataGridColumn<GroupData>(
id: 'parent',
header: 'Parent',
valueBuilder: (g) => g.parentName ?? '-',
width: const DataGridColumnWidth.flex(1),
@@ -198,15 +129,6 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
@override
Widget build(BuildContext context) {
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
return DataGrid<GroupData>(
provider: _gridProvider,
config: _buildConfig(),
@@ -11,15 +11,11 @@ class SecurityPage extends ConsumerWidget {
const SecurityPage({
super.key,
this.nav = SecurityNav.users,
this.routerState,
});
/// The current nav item to display.
final SecurityNav nav;
/// Router state for URL deep-linking (query params).
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
@@ -49,7 +45,7 @@ class SecurityPage extends ConsumerWidget {
),
// Section content
Expanded(
child: _SectionContent(nav: nav, routerState: routerState),
child: _SectionContent(nav: nav),
),
],
),
@@ -59,18 +55,17 @@ class SecurityPage extends ConsumerWidget {
/// Renders content for the selected nav item.
class _SectionContent extends StatelessWidget {
const _SectionContent({required this.nav, this.routerState});
const _SectionContent({required this.nav});
final SecurityNav nav;
final GoRouterState? routerState;
@override
Widget build(BuildContext context) {
switch (nav) {
case SecurityNav.users:
return UsersListPage(routerState: routerState);
return const UsersListPage();
case SecurityNav.groups:
return GroupsListPage(routerState: routerState);
return const GroupsListPage();
default:
return _PlaceholderSection(nav: nav);
}
+1 -6
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/security/presentation/pages/security_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Security room.
@@ -61,11 +60,7 @@ List<RouteBase> securityRoutes() {
GoRoute(
path: nav.path,
name: 'security${_capitalize(nav.id.replaceAll('-', '_'))}',
pageBuilder: (context, state) => noTransitionPage(
context,
state,
SecurityPage(nav: nav, routerState: state),
),
builder: (context, state) => SecurityPage(nav: nav),
),
];
}
@@ -1,10 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -65,10 +61,7 @@ class UserData {
/// Users list page using the shared DataGrid component.
class UsersListPage extends ConsumerStatefulWidget {
const UsersListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
const UsersListPage({super.key});
@override
ConsumerState<UsersListPage> createState() => _UsersListPageState();
@@ -77,19 +70,11 @@ class UsersListPage extends ConsumerStatefulWidget {
class _UsersListPageState extends ConsumerState<UsersListPage> {
late final StateNotifierProvider<DataGridController<UserData>,
DataGridState<UserData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
bool _isSyncing = false;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<UserData>(
dio: dio,
@@ -97,63 +82,13 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
fromJson: UserData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<UserData>(
source: source,
config: _buildConfig(),
idSelector: (u) => u.id,
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final params = PageUrlState(
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
Future<void> _syncFromAuthentik() async {
if (_isSyncing) return;
setState(() => _isSyncing = true);
@@ -190,7 +125,6 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
return DataGridConfig<UserData>(
columns: [
DataGridColumn<UserData>(
id: 'user',
header: 'User',
valueBuilder: (u) => u.name,
sortable: true,
@@ -199,14 +133,12 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
cellBuilder: (context, u) => _UserCell(user: u),
),
DataGridColumn<UserData>(
id: 'roles',
header: 'Roles',
valueBuilder: (u) => u.roles.join(', '),
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, u) => _RolesCell(roles: u.roles),
),
DataGridColumn<UserData>(
id: 'lastLogin',
header: 'Last Login',
valueBuilder: (u) => u.lastLoginFormatted,
width: const DataGridColumnWidth.fixed(120),
@@ -222,15 +154,6 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
@override
Widget build(BuildContext context) {
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
return DataGrid<UserData>(
provider: _gridProvider,
config: _buildConfig(),
@@ -1,306 +0,0 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
/// Settings page with user preferences.
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final currentTheme = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: authState.when(
data: (auth) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Page header
Text(
'Settings',
style: textTheme.headlineMedium,
),
const SizedBox(height: 32),
// Appearance section
_SectionHeader(title: 'Appearance'),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
_themeIcon(currentTheme),
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Theme', style: textTheme.titleMedium),
Text(
'Choose your preferred color scheme',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
DropdownButton<ThemeSetting>(
value: currentTheme,
underline: const SizedBox(),
onChanged: (value) {
if (value != null) {
_updateTheme(ref, value);
}
},
items: const [
DropdownMenuItem(
value: ThemeSetting.system,
child: Text('System'),
),
DropdownMenuItem(
value: ThemeSetting.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSetting.dark,
child: Text('Dark'),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Navigation section
_SectionHeader(title: 'Navigation'),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.home_outlined,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Default Room',
style: textTheme.titleMedium,
),
Text(
'Room to show when app opens',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
DropdownButton<String>(
value:
auth.preferences?.defaultRoom ?? 'front-hall',
underline: const SizedBox(),
onChanged: (value) {
if (value != null) {
_updateDefaultRoom(ref, value);
}
},
items: const [
DropdownMenuItem(
value: 'front-hall',
child: Text('Front Hall'),
),
DropdownMenuItem(
value: 'control-room',
child: Text('Control Room'),
),
DropdownMenuItem(
value: 'parlor',
child: Text('Parlor'),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Account section
_SectionHeader(title: 'Account'),
const SizedBox(height: 8),
Card(
child: Column(
children: [
_AccountInfoTile(
icon: Icons.person_outline,
label: 'Name',
value: auth.userName ?? 'Not available',
),
const Divider(height: 1),
_AccountInfoTile(
icon: Icons.email_outlined,
label: 'Email',
value: auth.userEmail ?? 'Not available',
),
if (auth.roles.isNotEmpty) ...[
const Divider(height: 1),
_AccountInfoTile(
icon: Icons.shield_outlined,
label: 'Roles',
value: auth.roles.map((r) => r.name).join(', '),
),
],
],
),
),
],
),
),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'Failed to load settings',
style: textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
error.toString(),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
IconData _themeIcon(ThemeSetting theme) {
return switch (theme) {
ThemeSetting.system => Icons.brightness_auto,
ThemeSetting.light => Icons.light_mode,
ThemeSetting.dark => Icons.dark_mode,
};
}
Future<void> _updateTheme(WidgetRef ref, ThemeSetting setting) async {
// Update local theme immediately for instant UI response
await ref.read(themeProvider.notifier).setSetting(setting);
// Sync to backend
try {
await ref.read(authProvider.notifier).updatePreferences(
theme: setting.name,
);
} catch (e) {
developer.log('Failed to sync theme preference: $e', name: 'settings');
}
}
Future<void> _updateDefaultRoom(WidgetRef ref, String room) async {
try {
await ref.read(authProvider.notifier).updatePreferences(
defaultRoom: room,
);
} catch (e) {
developer.log('Failed to sync default room: $e', name: 'settings');
}
}
}
/// Section header widget.
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Text(
title.toUpperCase(),
style: textTheme.labelMedium?.copyWith(
color: colorScheme.primary,
letterSpacing: 1.0,
fontWeight: FontWeight.w600,
),
);
}
}
/// Account info tile widget.
class _AccountInfoTile extends StatelessWidget {
const _AccountInfoTile({
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
)),
const SizedBox(height: 2),
Text(value, style: textTheme.titleMedium),
],
),
),
],
),
);
}
}
+10 -25
View File
@@ -1,37 +1,22 @@
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
import 'core/auth/auth_controller.dart';
import 'core/config/url_strategy.dart';
import 'version.g.dart';
void main() async {
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Enable semantics tree on web for accessibility and automation tools.
// This exposes widget identifiers to browser automation (Puppeteer, etc.)
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
developer.log(
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
// Use path-based URLs on web (no-op on mobile/desktop)
configureUrlStrategy();
debugPrint('🪣 ${AppVersion.name} v${AppVersion.fullVersion}');
// Initialize auth before starting the app.
// This handles OIDC callback and silent auth on web.
// If it returns false, we're redirecting and shouldn't continue.
final authReady = await AuthController.initialize();
if (!authReady) {
developer.log('Auth redirecting, not starting app', name: 'tatlock_ui');
return; // Don't run the app - browser is redirecting
}
runApp(const ProviderScope(child: TatlockApp()));
runApp(
const ProviderScope(
child: TatlockApp(),
),
);
}
+191 -15
View File
@@ -1,36 +1,61 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/features/settings/presentation/pages/settings_page.dart';
import 'package:tatlock_ui/shared/layouts/app_scaffold.dart';
part 'app_router.g.dart';
/// No-animation page builder for instant transitions
Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget child) {
return NoTransitionPage<void>(
key: state.pageKey,
child: child,
);
}
/// Route paths as constants.
abstract class AppRoutes {
static const frontHall = '/';
static const parlor = '/parlor';
static const settings = '/settings';
static const login = '/login';
}
/// Provides the GoRouter instance.
@riverpod
GoRouter appRouter(Ref ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: AppRoutes.frontHall,
debugLogDiagnostics: true,
redirect: (context, state) {
// No auth required in LAN mode
if (!AppConfig.requiresAuth) {
return null;
}
final isAuthenticated = authState.value?.isAuthenticated ?? false;
final isLoginRoute = state.matchedLocation == AppRoutes.login;
// If not authenticated, redirect to login (except if already on login)
if (!isAuthenticated && !isLoginRoute) {
return AppRoutes.login;
}
// If authenticated and on login page, redirect to home
if (isAuthenticated && isLoginRoute) {
return AppRoutes.frontHall;
}
return null;
},
routes: [
// Login route (outside shell - no app scaffold)
GoRoute(
path: AppRoutes.login,
name: 'login',
builder: (context, state) => const _LoginPage(),
),
// Main app routes (inside shell with app scaffold)
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
@@ -38,22 +63,21 @@ GoRouter appRouter(Ref ref) {
GoRoute(
path: AppRoutes.frontHall,
name: 'frontHall',
pageBuilder: (context, state) =>
noTransitionPage(context, state, const FrontHallPage()),
builder: (context, state) => const FrontHallPage(),
),
...controlRoomRoutes(),
...securityRoutes(),
GoRoute(
path: AppRoutes.parlor,
name: 'parlor',
pageBuilder: (context, state) =>
noTransitionPage(context, state, const _PlaceholderPage(title: 'Parlor')),
builder: (context, state) =>
const _PlaceholderPage(title: 'Parlor'),
),
GoRoute(
path: AppRoutes.settings,
name: 'settings',
pageBuilder: (context, state) =>
noTransitionPage(context, state, const SettingsPage()),
builder: (context, state) =>
const _PlaceholderPage(title: 'Settings'),
),
],
),
@@ -96,3 +120,155 @@ class _PlaceholderPage extends StatelessWidget {
}
}
/// Login page displayed when user is not authenticated.
class _LoginPage extends ConsumerWidget {
const _LoginPage();
@override
Widget build(BuildContext context, WidgetRef ref) {
final authAsync = ref.watch(authProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.home_work_outlined,
size: 64,
color: colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Tatlock Estate',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Sign in to access the estate management system',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
authAsync.when(
data: (_) => _buildSignInContent(context, ref),
loading: () => const Column(
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(height: 16),
Text('Checking authentication...'),
],
),
error: (error, _) => _buildErrorContent(context, ref, error),
),
],
),
),
),
),
),
);
}
Widget _buildSignInContent(BuildContext context, WidgetRef ref) {
if (kIsWeb) {
// Web: User needs to access via authenticated proxy
return Column(
children: [
Text(
'Please access Tatlock via the authenticated URL.\n'
'If you see this page, the proxy authentication may not be configured.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => ref.read(authProvider.notifier).signIn(),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
);
}
// Mobile: Show sign in button
return FilledButton.icon(
onPressed: () => ref.read(authProvider.notifier).signIn(),
icon: const Icon(Icons.login),
label: const Text('Sign in with Authentik'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
);
}
Widget _buildErrorContent(BuildContext context, WidgetRef ref, Object error) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
_formatError(error),
style: TextStyle(color: colorScheme.onErrorContainer),
),
),
],
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => ref.read(authProvider.notifier).signIn(),
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
);
}
String _formatError(Object error) {
final message = error.toString();
if (message.contains('user_cancelled')) {
return 'Sign in was cancelled';
}
if (message.contains('network')) {
return 'Network error. Please check your connection.';
}
if (message.contains('authenticated URL')) {
return 'Not authenticated - please access via the authenticated URL';
}
return 'Authentication failed. Please try again.';
}
}
-98
View File
@@ -1,98 +0,0 @@
import 'package:flutter/foundation.dart' show kIsWeb;
// Conditional import for web-only functionality
import 'url_state_stub.dart' if (dart.library.js_interop) 'url_state_web.dart'
as platform;
/// Parses and builds URL query parameters for page state.
///
/// Used to enable deep-linking for DataGrid and filter panel state.
/// Query params are read once on page load and updated via browser
/// replaceState to avoid GoRouter rebuild loops.
class PageUrlState {
const PageUrlState({
this.id,
this.filter,
this.search,
this.sortColumn,
this.sortDescending = false,
});
/// Opened document ID (view/edit mode).
final String? id;
/// Filter panel search value.
final String? filter;
/// DataGrid search value.
final String? search;
/// Column ID for sorting.
final String? sortColumn;
/// Sort direction (true = descending).
final bool sortDescending;
/// Parse from query parameters map.
factory PageUrlState.fromQueryParams(Map<String, String> params) {
return PageUrlState(
id: params['id'],
filter: params['filter'],
search: params['search'],
sortColumn: params['sort'],
sortDescending: params['order'] == 'desc',
);
}
/// Convert to query parameter map (omits empty/default values).
Map<String, String> toQueryParams() {
return {
if (id != null && id!.isNotEmpty) 'id': id!,
if (filter != null && filter!.isNotEmpty) 'filter': filter!,
if (search != null && search!.isNotEmpty) 'search': search!,
if (sortColumn != null && sortColumn!.isNotEmpty) 'sort': sortColumn!,
if (sortDescending) 'order': 'desc',
};
}
/// Create a copy with modified values.
PageUrlState copyWith({
String? id,
String? filter,
String? search,
String? sortColumn,
bool? sortDescending,
bool clearId = false,
}) {
return PageUrlState(
id: clearId ? null : (id ?? this.id),
filter: filter ?? this.filter,
search: search ?? this.search,
sortColumn: sortColumn ?? this.sortColumn,
sortDescending: sortDescending ?? this.sortDescending,
);
}
/// Whether any state is present.
bool get isEmpty =>
id == null &&
(filter == null || filter!.isEmpty) &&
(search == null || search!.isEmpty) &&
sortColumn == null;
@override
String toString() =>
'PageUrlState(id: $id, filter: $filter, search: $search, '
'sort: $sortColumn, desc: $sortDescending)';
}
/// Updates browser URL with query parameters without triggering navigation.
///
/// Uses browser's replaceState API on web, no-op on other platforms.
/// This allows the URL to stay in sync for bookmarking/sharing without
/// causing Flutter to rebuild.
void updateBrowserUrlParams(Map<String, String> params) {
if (kIsWeb) {
platform.updateBrowserUrlParams(params);
}
}
-4
View File
@@ -1,4 +0,0 @@
/// Stub implementation for non-web platforms.
void updateBrowserUrlParams(Map<String, String> params) {
// No-op on non-web platforms
}
-13
View File
@@ -1,13 +0,0 @@
import 'package:web/web.dart' as web;
/// Updates browser URL with query parameters without triggering navigation.
///
/// Uses browser's replaceState API to update the URL bar for bookmarking
/// without causing Flutter/GoRouter to rebuild the page.
void updateBrowserUrlParams(Map<String, String> params) {
final currentUri = Uri.parse(web.window.location.href);
final newUri = currentUri.replace(
queryParameters: params.isEmpty ? null : params,
);
web.window.history.replaceState(null, '', newUri.toString());
}
@@ -45,7 +45,6 @@ enum DataGridColumnAlignment {
/// Column definition for DataGrid.
class DataGridColumn<T> {
const DataGridColumn({
required this.id,
required this.header,
required this.valueBuilder,
this.cellBuilder,
@@ -59,9 +58,6 @@ class DataGridColumn<T> {
this.tooltip,
});
/// Unique identifier for this column (used in URL deep-linking).
final String id;
/// Column header text.
final String header;
@@ -15,15 +15,9 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
required this.source,
required this.config,
required this.idSelector,
String? initialSearch,
int? initialSortColumnIndex,
bool? initialSortDescending,
}) : super(DataGridState<T>(
searchQuery: initialSearch ?? '',
sortColumnIndex:
initialSortColumnIndex ?? config.defaultSortColumn,
sortDescending:
initialSortDescending ?? config.defaultSortDescending,
sortColumnIndex: config.defaultSortColumn,
sortDescending: config.defaultSortDescending,
)) {
// Initial load
_load();
@@ -35,13 +29,8 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// Grid configuration.
final DataGridConfig<T> config;
/// Function to extract unique ID from an item (used for selection and URL deep-linking).
///
/// Returns a unique string identifier for each row. Can be:
/// - A simple ID field: `(item) => item.id.toString()`
/// - A combination of fields: `(item) => '${item.name}_${item.type}'`
/// - Any unique identifier suitable for URLs
final String Function(T item) idSelector;
/// Function to extract unique ID from an item.
final Object Function(T item) idSelector;
/// Debounce timer for search.
Timer? _searchDebounce;
@@ -196,7 +185,7 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
if (!config.rowsSelectable) return;
final id = idSelector(item);
final newSelection = Set<String>.from(state.selectedIds);
final newSelection = Set<Object>.from(state.selectedIds);
if (newSelection.contains(id)) {
newSelection.remove(id);
@@ -298,35 +287,17 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// idSelector: (c) => c.id,
/// );
/// ```
///
/// For URL deep-linking, pass initial state from URL params:
/// ```dart
/// final gridProvider = dataGridProvider<Container>(
/// source: source,
/// config: config,
/// idSelector: (c) => c.id,
/// initialSearch: urlState.search,
/// initialSortColumnIndex: _columnIndexForId(urlState.sortColumn),
/// initialSortDescending: urlState.sortDescending,
/// );
/// ```
StateNotifierProvider<DataGridController<T>, DataGridState<T>>
dataGridProvider<T>({
required DataGridSource<T> source,
required DataGridConfig<T> config,
required String Function(T) idSelector,
String? initialSearch,
int? initialSortColumnIndex,
bool? initialSortDescending,
required Object Function(T) idSelector,
}) {
return StateNotifierProvider<DataGridController<T>, DataGridState<T>>(
(ref) => DataGridController<T>(
source: source,
config: config,
idSelector: idSelector,
initialSearch: initialSearch,
initialSortColumnIndex: initialSortColumnIndex,
initialSortDescending: initialSortDescending,
),
);
}
@@ -31,7 +31,7 @@ sealed class DataGridState<T> with _$DataGridState<T> {
@Default(false) bool sortDescending,
/// Currently selected item IDs (if selectable).
@Default({}) Set<String> selectedIds,
@Default({}) Set<Object> selectedIds,
/// Current page (for paginated mode).
@Default(0) int currentPage,
+2 -166
View File
@@ -1,9 +1,5 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/routing/app_router.dart';
@@ -11,11 +7,6 @@ import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart';
/// Main application scaffold with top header navigation.
///
/// Handles authentication automatically:
/// - If not authenticated, auto-initiates OIDC flow
/// - Shows loading state during authentication
/// - Shows error state if auth fails (with retry)
///
/// Layout structure per UI_LAYOUT.md:
/// ```
/// ┌─────────────────────────────────────────────────────────────────┐
@@ -24,62 +15,16 @@ import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart';
/// │ BODY: Room page content (may include room-specific sidebar) │
/// └─────────────────────────────────────────────────────────────────┘
/// ```
class AppScaffold extends ConsumerStatefulWidget {
class AppScaffold extends StatelessWidget {
const AppScaffold({super.key, required this.child});
final Widget child;
@override
ConsumerState<AppScaffold> createState() => _AppScaffoldState();
}
class _AppScaffoldState extends ConsumerState<AppScaffold> {
// Header height must match TopHeaderBar._headerHeight
static const double _headerHeight = 56.0;
bool _authInitiated = false;
@override
Widget build(BuildContext context) {
// No auth required in LAN mode - show content directly
if (!AppConfig.requiresAuth) {
return _buildScaffold(context);
}
// Watch auth state (works for both web and mobile)
final authAsync = ref.watch(authProvider);
return authAsync.when(
data: (authState) {
if (authState.isAuthenticated) {
// Authenticated - show the app
_authInitiated = false; // Reset for next time
return _buildScaffold(context);
}
// On web, NPM handles auth - if we're here without auth, something is wrong
// (NPM should have redirected to Authentik before we loaded)
if (kIsWeb) {
return _buildAuthErrorScreen(context, 'Authentication required');
}
// Mobile: Not authenticated - auto-initiate OIDC
if (!_authInitiated) {
_authInitiated = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(authProvider.notifier).signIn();
});
}
// Show loading while redirecting to Authentik
return _buildAuthLoadingScreen(context, 'Redirecting to sign in...');
},
loading: () => _buildAuthLoadingScreen(context, 'Loading user info...'),
error: (error, _) => _buildAuthErrorScreen(context, error),
);
}
Widget _buildScaffold(BuildContext context) {
return Scaffold(
body: Stack(
children: [
@@ -87,7 +32,7 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
Positioned.fill(
child: Padding(
padding: const EdgeInsets.only(top: _headerHeight),
child: widget.child,
child: child,
),
),
@@ -106,115 +51,6 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
);
}
Widget _buildAuthLoadingScreen(BuildContext context, String message) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_work_outlined,
size: 64,
color: colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Tatlock Estate',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 32),
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildAuthErrorScreen(BuildContext context, Object error) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Authentication Failed',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_formatError(error),
style: TextStyle(color: colorScheme.onErrorContainer),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
_authInitiated = false;
ref.read(authProvider.notifier).signIn();
},
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
),
),
),
),
),
);
}
String _formatError(Object error) {
final message = error.toString();
if (message.contains('user_cancelled')) {
return 'Sign in was cancelled';
}
if (message.contains('network')) {
return 'Network error. Please check your connection.';
}
return 'Authentication failed. Please try again.';
}
int _selectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
+10 -17
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'panel_header.dart';
@@ -200,22 +199,17 @@ class _NavTile extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Semantics(
identifier: NavSemantics.item(item.id),
label: item.label,
button: true,
selected: isSelected,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
@@ -264,7 +258,6 @@ class _NavTile extends StatelessWidget {
),
),
),
),
);
}
}
+73 -216
View File
@@ -1,11 +1,7 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
import 'package:tatlock_ui/routing/app_router.dart';
/// Profile dropdown menu in the header.
@@ -17,213 +13,90 @@ class ProfileDropdown extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final currentTheme = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return authState.when(
data: (auth) => Semantics(
identifier: ProfileSemantics.button,
label: 'Profile menu',
button: true,
child: PopupMenuButton<String>(
offset: const Offset(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Tooltip(
message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
child: CircleAvatar(
radius: 18,
backgroundColor: colorScheme.primaryContainer,
child: auth.isAuthenticated
? Text(
_getInitials(auth.userName),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
)
: Icon(
Icons.person_outline,
size: 20,
data: (auth) => PopupMenuButton<String>(
offset: const Offset(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Tooltip(
message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
child: CircleAvatar(
radius: 18,
backgroundColor: colorScheme.primaryContainer,
child: auth.isAuthenticated
? Text(
_getInitials(auth.userName),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
)
: Icon(
Icons.person_outline,
size: 20,
color: colorScheme.onPrimaryContainer,
),
),
),
itemBuilder: (context) => [
// User info header (non-selectable)
PopupMenuItem<String>(
enabled: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
style: Theme.of(context).textTheme.titleSmall,
),
if (auth.userEmail != null)
Text(
auth.userEmail!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
itemBuilder: (context) => [
// User info header (non-selectable)
PopupMenuItem<String>(
enabled: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
const PopupMenuDivider(),
// Settings
const PopupMenuItem<String>(
value: 'settings',
child: Row(
children: [
Icon(Icons.settings_outlined, size: 20),
SizedBox(width: 12),
Text('Settings'),
],
),
),
// Logout (only if authenticated)
if (auth.isAuthenticated)
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Text(
auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
style: Theme.of(context).textTheme.titleSmall,
),
if (auth.userEmail != null)
Text(
auth.userEmail!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Icon(Icons.logout, size: 20),
SizedBox(width: 12),
Text('Logout'),
],
),
),
const PopupMenuDivider(),
// Settings
PopupMenuItem<String>(
value: 'settings',
child: Semantics(
identifier: ProfileSemantics.settings,
child: const Row(
children: [
Icon(Icons.settings_outlined, size: 20),
SizedBox(width: 12),
Text('Settings'),
],
),
),
),
// Theme submenu header
PopupMenuItem<String>(
enabled: false,
height: 32,
child: Text(
'THEME',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
),
// Theme: System
PopupMenuItem<String>(
value: 'theme_system',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeSystem,
child: Row(
children: [
Icon(
Icons.brightness_auto,
size: 18,
color: currentTheme == ThemeSetting.system
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'System',
style: currentTheme == ThemeSetting.system
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.system)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
// Theme: Light
PopupMenuItem<String>(
value: 'theme_light',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeLight,
child: Row(
children: [
Icon(
Icons.light_mode,
size: 18,
color: currentTheme == ThemeSetting.light
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'Light',
style: currentTheme == ThemeSetting.light
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.light)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
// Theme: Dark
PopupMenuItem<String>(
value: 'theme_dark',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeDark,
child: Row(
children: [
Icon(
Icons.dark_mode,
size: 18,
color: currentTheme == ThemeSetting.dark
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'Dark',
style: currentTheme == ThemeSetting.dark
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.dark)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
const PopupMenuDivider(),
// Logout (only if authenticated)
if (auth.isAuthenticated)
PopupMenuItem<String>(
value: 'logout',
child: Semantics(
identifier: ProfileSemantics.logout,
child: const Row(
children: [
Icon(Icons.logout, size: 20),
SizedBox(width: 12),
Text('Logout'),
],
),
),
),
],
onSelected: (value) {
switch (value) {
case 'settings':
context.go(AppRoutes.settings);
case 'theme_system':
_updateTheme(ref, ThemeSetting.system);
case 'theme_light':
_updateTheme(ref, ThemeSetting.light);
case 'theme_dark':
_updateTheme(ref, ThemeSetting.dark);
case 'logout':
ref.read(authProvider.notifier).signOut();
}
},
),
],
onSelected: (value) {
switch (value) {
case 'settings':
context.go(AppRoutes.settings);
case 'logout':
ref.read(authProvider.notifier).signOut();
}
},
),
loading: () => const CircleAvatar(
radius: 18,
@@ -253,20 +126,4 @@ class ProfileDropdown extends ConsumerWidget {
}
return name[0].toUpperCase();
}
/// Update theme locally and sync to API.
Future<void> _updateTheme(WidgetRef ref, ThemeSetting setting) async {
// Update local theme immediately for instant UI response
await ref.read(themeProvider.notifier).setSetting(setting);
// Sync to backend (fire-and-forget, errors logged not shown)
try {
await ref.read(authProvider.notifier).updatePreferences(
theme: setting.name,
);
} catch (e) {
// Theme still works locally even if API sync fails
developer.log('Failed to sync theme preference: $e', name: 'profile');
}
}
}
+11 -18
View File
@@ -1,7 +1,6 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'profile_dropdown.dart';
@@ -128,23 +127,17 @@ class TopHeaderBar extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Semantics(
identifier: RoomTabSemantics.forIndex(index),
label: room.label,
button: true,
selected: isSelected,
child: Tooltip(
message: room.label,
child: IconButton(
icon: Icon(
isSelected ? room.selectedIcon : room.icon,
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
),
onPressed: () => onRoomSelected(index),
style: IconButton.styleFrom(
backgroundColor:
isSelected ? colorScheme.primaryContainer : null,
),
child: Tooltip(
message: room.label,
child: IconButton(
icon: Icon(
isSelected ? room.selectedIcon : room.icon,
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
),
onPressed: () => onRoomSelected(index),
style: IconButton.styleFrom(
backgroundColor:
isSelected ? colorScheme.primaryContainer : null,
),
),
),
+13
View File
@@ -0,0 +1,13 @@
// GENERATED FILE - DO NOT EDIT
// Generated by build_runner from pubspec.yaml
/// Application version information from pubspec.yaml
class AppVersion {
AppVersion._();
static const String name = 'tatlock_ui';
static const String description = 'Tatlock - a Home Lab AI';
static const String version = '0.3.3';
static const int buildNumber = 1;
static const String fullVersion = '0.3.3+1';
}
-53
View File
@@ -1,53 +0,0 @@
# Increase buffer size for large headers from Authentik
proxy_buffers 8 16k;
proxy_buffer_size 32k;
# Exclude static assets from forward auth
# These paths bypass auth_request but still proxy to upstream
location ~ ^/(manifest\.json|favicon\.(ico|png)|health|icons|assets) {
auth_request off;
proxy_pass $forward_scheme://$server:$port;
}
# Forward authentication via standalone outpost
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin;
# Capture auth response headers
auth_request_set $auth_cookie $upstream_http_set_cookie;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
# Forward auth headers to application
add_header Set-Cookie $auth_cookie;
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-groups $authentik_groups;
proxy_set_header X-authentik-email $authentik_email;
proxy_set_header X-authentik-name $authentik_name;
proxy_set_header X-authentik-uid $authentik_uid;
# Outpost proxy location
location /outpost.goauthentik.io {
proxy_pass https://localhost:9444/outpost.goauthentik.io;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# Signin redirect handler
location @goauthentik_proxy_signin {
internal;
return 302 /outpost.goauthentik.io/start?rd=$request_uri;
}
+1 -5
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.3.0+1
version: 1.0.3+1
environment:
sdk: ^3.10.4
@@ -30,8 +30,6 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
# State Management
flutter_riverpod: ^3.0.0
@@ -54,8 +52,6 @@ dependencies:
# Authentication (OIDC/OAuth2)
flutter_appauth: ^8.0.0
crypto: ^3.0.3
web: ^1.1.0
# UI
flex_color_scheme: ^8.1.0
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env dart
// Generates web/health/health.json from pubspec.yaml
// Run: dart run tool/generate_health_json.dart
// ignore_for_file: avoid_print
import 'dart:convert';
import 'dart:io';
import 'package:yaml/yaml.dart';
void main() {
final pubspecFile = File('pubspec.yaml');
if (!pubspecFile.existsSync()) {
stderr.writeln('Error: pubspec.yaml not found');
exit(1);
}
final pubspecContent = pubspecFile.readAsStringSync();
final pubspec = loadYaml(pubspecContent) as YamlMap;
final name = pubspec['name'] as String;
final description = pubspec['description'] as String? ?? '';
final versionString = pubspec['version'] as String;
// Parse version: "1.0.3+1" -> version="1.0.3", buildNumber=1
final versionParts = versionString.split('+');
final version = versionParts[0];
final buildNumber = versionParts.length > 1 ? int.parse(versionParts[1]) : 0;
final health = {
'status': 'healthy',
'name': name,
'title': description,
'version': version,
'buildNumber': buildNumber,
'fullVersion': '$version+$buildNumber',
};
final healthDir = Directory('web/health');
if (!healthDir.existsSync()) {
healthDir.createSync(recursive: true);
}
final healthFile = File('web/health/health.json');
healthFile.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(health),
);
print('Generated web/health/health.json with version $version+$buildNumber');
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>OK</title>
</head>
<body>OK</body>
</html>
-8
View File
@@ -1,8 +0,0 @@
{
"status": "healthy",
"name": "tatlock_ui",
"title": "Tatlock - a Home Lab AI",
"version": "1.0.4",
"buildNumber": 1,
"fullVersion": "1.0.4+1"
}
-30
View File
@@ -1,30 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Health Check</title>
<style>
body { font-family: monospace; padding: 20px; background: #1a1a1a; color: #0f0; }
.healthy { color: #0f0; }
.error { color: #f00; }
pre { background: #222; padding: 15px; border-radius: 5px; }
</style>
</head>
<body>
<h1 id="status">Loading...</h1>
<pre id="data"></pre>
<script>
fetch('./health.json')
.then(r => r.json())
.then(data => {
document.getElementById('status').textContent = data.status?.toUpperCase() || 'OK';
document.getElementById('status').className = data.status === 'healthy' ? 'healthy' : 'error';
document.getElementById('data').textContent = JSON.stringify(data, null, 2);
})
.catch(err => {
document.getElementById('status').textContent = 'ERROR';
document.getElementById('status').className = 'error';
document.getElementById('data').textContent = err.message;
});
</script>
</body>
</html>
-9
View File
@@ -27,19 +27,10 @@
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="favicon.ico"/>
<link rel="icon" type="image/png" href="favicon.png"/>
<title>Tatlock</title>
<link rel="manifest" href="manifest.json">
<style>
body {
margin: 0;
padding: 0;
/* Match Flutter's dark theme scaffold background (FlexScheme.aquaBlue) */
background-color: #111111;
}
</style>
</head>
<body>
<script src="flutter_bootstrap.js" async></script>