Compare commits

...
44 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 40ba6a869d fix: apply default room preference on app load
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m7s
- Root `/` now redirects to user's preferred default room
- Front Hall moved to `/front-hall` route (was `/`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:00:47 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c494ace5d8 feat: add URL deep-linking for DataGrids
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m5s
- Add PageUrlState utility for URL ↔ state serialization
- Add column `id` field for unique column identification in URLs
- Update idSelector to return String for URL compatibility
- All DataGrid pages now support URL params: search, sort, order, id
- Browser URL updates via replaceState (no GoRouter rebuilds)
- Add FilterPanelSemantics for filter panel semantic IDs
- Add TESTING.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:59:08 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a6c51f757a feat(semantics): add semantic labels for UI automation
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
- Created lib/core/semantics/ with semantic ID constants and helper widget
- Enabled SemanticsBinding on web builds for accessibility tree exposure
- Added semantic IDs to:
  - ProfileDropdown (button, settings, theme options, logout)
  - TopHeaderBar room tabs (frontHall, controlRoom, security, parlor)
  - NavPanel items (nav_item_{id})

This enables browser automation tools like Puppeteer and WebDriver to
discover and interact with Flutter widgets via the accessibility tree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:21:43 +01:00
Jeroen SchweitzerandClaude Opus 4.5 04032a6dbc fix(auth): remove auto-signout on 401 in AuthInterceptor
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m2s
The AuthInterceptor was calling signOut() on any 401 error, which caused
the theme toggle to trigger logout when the preferences API returned 401.
Now 401 errors propagate to calling code for graceful handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 10:16:43 +01:00
Jeroen SchweitzerandClaude Opus 4.5 6b6614f482 fix(auth): prevent AuthNotifier auto-dispose causing theme toggle logout
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m1s
Applied @persistentRiverpod annotation to AuthNotifier so it persists
for app lifetime. Previously, theme changes could trigger AuthProvider
rebuild via auto-dispose, causing AsyncLoading state and auth issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 09:50:48 +01:00
Jeroen SchweitzerandClaude Opus 4.5 265ca5959d fix: theme toggle causing auth issues due to auto-dispose
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
- Add @persistentRiverpod annotation for providers that need keepAlive
- ThemeProvider now persists for app lifetime
- Refactored API clients to use @persistentRiverpod
- Documented in ARCHITECTURE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:44:03 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c617d7dfbb feat: add settings page and theme toggle in user dropdown
Build and Push / build (push) Successful in 3m3s
Build and Push / release (push) Successful in 3s
- Settings page with Appearance, Navigation, and Account sections
- Theme toggle (System/Light/Dark) in profile dropdown
- Theme syncs with API preferences on login
- Default room preference syncs with backend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:24:21 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f0f5e08c46 chore: match HTML background to Flutter dark theme
Changed from #1a1a2e to #111111 to match FlexScheme.aquaBlue scaffold background.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:40:39 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9db677bee2 chore: rename Stack section to Stack Management
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:39:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 2e1d1dd457 refactor: reorganize Control Room navigation
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 3m0s
- Consolidate Containers and Proxy Hosts under "Stack" section
- Add "Data Management" section with database browser placeholders:
  PostgreSQL, Redis, Qdrant, Neo4j
- Remove unused nav items: Networks, Volumes, Images (Portainer),
  Redirections, Streams, Certificates (NPM)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:34:25 +01:00
Jeroen SchweitzerandClaude Opus 4.5 34fdc77818 fix: prevent API client provider auto-dispose causing Ref invalidation
Build and Push / release (push) Successful in 4s
Build and Push / build (push) Successful in 2m58s
API client providers (coreApiClientProvider, tatlockApiClientProvider) now
use keepAlive: true. This fixes "DioException [unknown]: null" errors on
pages like /security/users where ref.read() was used without subscription.

The AuthInterceptor stores a Ref that became invalid when the provider
auto-disposed after a one-time read.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:08:46 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c374ecbffb feat: add visible version debugPrint on app startup
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:36:52 +01:00
Jeroen SchweitzerandClaude Opus 4.5 55a9cbdc6e chore: release v1.1.10
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
Remove page swipe transitions - instant navigation via NoTransitionPage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:30:20 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a6fc9daab9 docs: add NPM forward auth config for reference
Config for home.schweitz.net with Authentik forward auth:
- Static assets excluded via auth_request off
- Proper proxy pass to upstream

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:19:31 +01:00
Jeroen SchweitzerandClaude Opus 4.5 8b3bff7df0 chore: release v1.1.9
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
Move health check to /health directory for NPM forward auth exclusion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:54:17 +01:00
Jeroen SchweitzerandClaude Opus 4.5 2b2ddc1b1b docs: simplify release steps - CI auto-triggers on v* tag push
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:26:54 +01:00
Jeroen SchweitzerandClaude Opus 4.5 0d5986b81a chore: release v1.1.8
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m10s
Dark background on web/index.html to prevent white flash during auth redirects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:19:10 +01:00
Jeroen SchweitzerandClaude Opus 4.5 31e3306997 chore: release v1.1.7
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Remove callback route - AuthController handles it before app starts:
- Removed /callback route from Flutter router
- Removed _OidcCallbackPage widget
- Auth is now invisible - no Flutter UI during auth flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:41:43 +01:00
Jeroen SchweitzerandClaude Opus 4.5 5f3ff7f31a chore: release v1.1.6
Build and Push / build (push) Successful in 3m3s
Build and Push / release (push) Successful in 3s
Auth moved to standalone controller outside Riverpod:
- New AuthController runs in main() before runApp()
- 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:14:20 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f90b4a0963 fix: skip silent OIDC on callback page to prevent race condition
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 3m3s
AuthProvider.build() was initiating silent OIDC while the callback
page was processing the auth code, causing PKCE state to be cleared.
Now checks if on /callback route and skips silent OIDC initiation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:51:39 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a95296e1fc fix: fall back to regular OIDC when silent auth fails
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m16s
When prompt=none fails with login_required (no Authentik session),
automatically redirect to regular OIDC flow to show login UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:20:57 +01:00
Jeroen SchweitzerandClaude Opus 4.5 3fa97bb0b0 feat: silent OIDC auth with JWT Bearer tokens for web
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
- Add prompt=none to silently obtain JWT when Authentik session exists
- Flutter sends Bearer token to core-api instead of forward auth cookies
- Fixes cross-subdomain cookie issues between home/api.schweitz.net
- Callback syncs with /auth/sync for user profile and roles
- API interceptor now adds Bearer token on web

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:11:07 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f0b32ff68b fix(auth): skip Flutter OIDC on web, rely on NPM forward auth
Build and Push / release (push) Successful in 4s
Build and Push / build (push) Successful in 3m9s
On web, NPM forward auth handles authentication at the proxy level.
By the time the Flutter app loads, the user is already authenticated.
Skip the redundant Flutter OIDC flow that was causing Riverpod
"Ref disposed" errors from conflicting auth state updates.

Mobile still uses Flutter's OIDC flow as before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:01:33 +01:00
Jeroen SchweitzerandClaude Opus 4.5 790ae41171 fix(auth): Riverpod lifecycle error + Authentik logout
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m2s
- Fix "Cannot use Ref after disposed" error in OIDC callback page
  - Store notifier reference before async gap
  - Add mounted check at start of processing
- Add proper SSO logout via Authentik end_session_endpoint
  - Clears local tokens AND redirects to Authentik logout
  - Returns to app after Authentik session ends

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:50:32 +01:00
Jeroen SchweitzerandClaude Opus 4.5 8625ac6574 fix(build): ensure fresh Flutter build on each deploy
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m1s
- Add flutter clean before build to prevent stale cached artifacts
- Add VERSION build arg for explicit cache busting
- Reorder build steps: clean → pub get → build_runner → health.json → build
- Replace deprecated dart:html with package:web in iframe_view_web.dart
- Add lint ignore to generate_health_json.dart

Fixes issue where Docker layer caching kept old main.dart.js
while regenerating health.json with new version number.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:18:01 +01:00
Jeroen SchweitzerandClaude Opus 4.5 16bad327f1 feat(auth): auto-initiate OIDC, remove login page
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- AppScaffold now handles auth: auto-starts OIDC if not authenticated
- Removed /login route and _LoginPage (no longer needed)
- Shows loading screen during auth, error screen on failure
- Seamless auth when Authentik session already exists

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:52:13 +01:00
Jeroen SchweitzerandClaude Opus 4.5 b35f495537 feat(auth): extract user info from JWT, skip core-api sync
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- Decode JWT claims (name, email, groups) directly in Flutter
- No longer calls /auth/sync endpoint (avoids CORS preflight issues)
- Bearer token used for subsequent API authentication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:38:19 +01:00
Jeroen SchweitzerandClaude Opus 4.5 346ca75d68 fix(auth): callback route redirect order + favicon.ico
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- Move callback route exception check BEFORE auth redirect check
- This was preventing OIDC token exchange from ever happening
- Add favicon.ico to web root for browser tab icon

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:17:03 +01:00
Jeroen SchweitzerandClaude Opus 4.5 0c27c10a2c fix(auth): defer OIDC callback processing to post-frame
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m1s
Wraps _processCallback() in addPostFrameCallback to avoid Riverpod
"Tried to modify a provider while the widget tree was building" error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:03:35 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ff5df30c53 feat(web): switch to path-based URL strategy
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Remove hash from URLs (/#/login -> /login) using usePathUrlStrategy().
Uses conditional imports to only apply on web, keeping mobile/desktop
builds unaffected.

Required for OIDC callback to work - Authentik redirects to /callback
which Flutter now recognizes as a route.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:55:36 +01:00
Jeroen SchweitzerandClaude Opus 4.5 0a6e9de4a8 fix(auth): persist PKCE state in sessionStorage
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Store OIDC code_verifier and state in sessionStorage instead of
static memory variables. This fixes the "No code verifier" error
that occurred after Authentik redirect because the Flutter app
restarts and loses in-memory state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:40:38 +01:00
Jeroen SchweitzerandClaude Opus 4.5 4c377b19c4 chore: release v1.0.6
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Fix version generation in CI/CD builds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:15:14 +01:00
Jeroen Schweitzer 8494b4ad7f add version debug print to console on start 2026-01-04 12:15:05 +01:00
Jeroen SchweitzerandClaude Opus 4.5 e596b99e39 chore: stop tracking generated files
- Remove version.g.dart and health.json from git
- These are now regenerated during CI/CD build from pubspec.yaml
- Fixes version mismatch issue in deployments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:12:10 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9c41a8805d chore: release v1.0.5
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
Web authentication now uses OIDC Authorization Code flow with PKCE
instead of NPM forward auth. Added callback route and web utilities.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:01:42 +01:00
Jeroen SchweitzerandClaude Opus 4.5 45de2591a7 feat(health): add health.json with version info
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- Generate health.json from pubspec.yaml at build time
- health.html fetches and displays JSON with version, title, status
- Added tool/generate_health_json.dart script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:51:47 +01:00
Jeroen SchweitzerandClaude Opus 4.5 2fe6067085 fix(auth): correct endpoint path /auth/me → /auth/users/me
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:42:07 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ce2dfcd13c fix(build): add production API URLs to Docker build
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m57s
Dockerfile now passes --dart-define flags for CORE_API_URL and
TATLOCK_API_URL pointing to schweitz.net domains. This enables
requiresAuth=true, fixing auth being completely skipped in production.

Also added service port reference table to AGENTS.md.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:19:32 +01:00
Jeroen SchweitzerandClaude Opus 4.5 672f497733 fix(auth): enable cookie credentials for web API requests
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m56s
Configure Dio with BrowserHttpClientAdapter and withCredentials: true
for web platform, allowing session cookies to be sent with XHR requests.
This fixes NPM forward auth not working for API calls.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:56:56 +01:00
Jeroen SchweitzerandClaude Opus 4.5 360c7a8bb3 chore: release v1.0.0
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 22:19:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a991f5ebed test(auth): add unit tests for auth system
- Add permissions_test.dart (51 tests for Domain, Action, Role)
- Add auth_state_test.dart (16 tests for AuthState)
- Add user_preferences_test.dart (12 tests for UserPreferences)
- Add permission_gate_test.dart (10 tests for permission logic)

All 60 tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:15:50 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f1b2b0430f feat(auth): implement dual-flow authentication (web + mobile)
Add complete authentication system supporting both web (NPM forward auth)
and mobile (OIDC) authentication flows.

Web flow:
- Check /auth/me on startup to detect NPM forward auth session
- Cookies handled by proxy, no Bearer tokens needed

Mobile flow:
- flutter_appauth for OIDC Authorization Code + PKCE
- POST /auth/sync to get user profile and roles
- Token storage in SharedPreferences

Shared:
- Permission system with Domain/Action enums and Role class
- PermissionGate and AdminGate widgets for UI permission checks
- Route guards redirecting unauthenticated users to login
- Login page with platform-specific messaging

Platform config:
- iOS: CFBundleURLTypes for net.schweitz.tatlock://
- Android: appAuthRedirectScheme, minSdk 23

Docs:
- Added Freezed 3.x sealed class documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:56:11 +01:00
Jeroen SchweitzerandClaude Opus 4.5 806b0a98c8 ci: trigger build on version tag push with auto-release
Changed workflow to:
- Trigger on push of v* tags instead of release publish
- Auto-create Gitea release via API
- Then build and push Docker image

This simplifies deployment: just push a version tag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:40:56 +01:00
Jeroen SchweitzerandClaude Opus 4.5 6c271489a9 refactor: add shared StoplightColors for consistent status colors
- Add StoplightColors class with green/orange/red pastel colors
- Update dashboard gauges to use StoplightColors.forPercent()
- Update air quality levels to use shared stoplight colors
- Provides consistent color scheme for all threshold-based indicators

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:33:42 +01:00
71 changed files with 5149 additions and 325 deletions
+17 -5
View File
@@ -1,15 +1,27 @@
name: Build and Push
on:
release:
types: [published]
push:
tags:
- 'v*'
jobs:
build:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create Gitea Release
run: |
curl -sf -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
build:
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
+5 -4
View File
@@ -13,11 +13,12 @@ pubspec.lock
*.gr.dart
*.mocks.dart
# 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
# All generated *.g.dart files (from json_serializable, riverpod, version_builder)
# These are regenerated by build_runner during CI/CD builds
lib/**/*.g.dart
!lib/version.g.dart
# Generated health.json (regenerated by tool/generate_health_json.dart during build)
web/health.json
# IDE
.idea/
+14 -10
View File
@@ -22,14 +22,20 @@ This document contains instructions and documentation references for AI assistan
* Related repos: , `core-api`, `tatlock`, `library-desk`, `scheduler`, `portainer-core`
### 🐳 Deployment & Infrastructure
**⚠️ IMPORTANT: Service Port Reference**
| Service | LAN Port | External URL | Notes |
|---------|----------|--------------|-------|
| **Core API** | 8083 | `api.schweitz.net` | FastAPI backend for this UI |
| **Tatlock API** | 8000 | `tatlock.schweitz.net` | Legacy Python API (Ollama proxy) |
| **Tatlock UI** | 9999 | `home.schweitz.net` | This Flutter app |
* **Full stack documentation**: Available in the `portainer-core` repo
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
* Access: `curl http://192.168.86.149:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
* Contains: All service ports, URLs, Redis DB allocations, external domains
* **Tatlock deployment**:
* LAN: `http://192.168.86.149:8000`
* External: `tatlock.schweitz.net` (behind Authentik SSO)
* Redis DBs: 1 (memory), 6 (benchmarks)
* **Health check**: `curl http://192.168.86.149:8000/health`
* **Health checks**:
* Core API: `curl http://192.168.86.149:8083/health`
* Tatlock API: `curl http://192.168.86.149:8000/health`
### 🛡️ Git Discipline
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
@@ -53,10 +59,8 @@ 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`
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)
CI/CD auto-triggers when a tag starting with `v` is pushed.
**What happens on release:**
+289
View File
@@ -7,6 +7,295 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.1] - 2026-01-05
### Fixed
- Default room preference now applies on app load
- Root `/` redirects to user's preferred default room
- Front Hall moved to `/front-hall` route (was `/`)
## [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
- Fixed auth endpoint path: `/auth/me``/auth/users/me`
## [1.0.2] - 2026-01-03
### Fixed
- Production Docker build now uses correct API URLs
- Added `--dart-define` flags for `CORE_API_URL` and `TATLOCK_API_URL`
- This enables `requiresAuth=true` so authentication is actually triggered
- Updated AGENTS.md with clear service port reference table
## [1.0.1] - 2026-01-03
### Fixed
- Web authentication now works correctly with NPM forward auth
- Dio client sends cookies with requests via `withCredentials: true`
- Added platform-specific adapters (native vs web) for proper cookie handling
## [1.0.0] - 2026-01-03
### Added
- **Authentication System** - Dual-flow auth supporting web (NPM forward auth) and mobile (OIDC)
- `AuthState` model with roles, permissions, and user preferences
- `AuthProvider` with automatic web session detection via `/auth/me`
- Permission system with Domain/Action enums and hierarchical access levels
- `PermissionGate` and `AdminGate` widgets for UI permission checks
- `Role` model with `{domain}.{category}:{action}` format parsing
- Route guards redirect unauthenticated users to login page
- Login page with Authentik OAuth redirect
- Mobile auth platform configuration (iOS URL schemes, Android AppAuth)
- Comprehensive auth test suite (60 unit tests)
### Changed
- API interceptor skips Bearer tokens on web (uses cookies via NPM forward auth)
- Router integrates auth state for protected route access
- **First stable release** - Core functionality complete for home lab dashboard
## [0.3.3] - 2026-01-03
### Added
+20 -6
View File
@@ -3,20 +3,34 @@ FROM ghcr.io/cirruslabs/flutter:stable AS builder
WORKDIR /app
# Copy dependency files first for better caching
COPY pubspec.yaml ./
# 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"
# Get dependencies (generates pubspec.lock)
# Copy dependency files first for better caching
COPY pubspec.yaml pubspec.lock* ./
# Get dependencies
RUN flutter pub get
# Copy the rest of the application
COPY . .
# Generate code with build_runner
# 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)
RUN dart run build_runner build --delete-conflicting-outputs
# Build for web release
RUN flutter build web --release
# 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 \
--dart-define=CORE_API_URL=https://api.schweitz.net \
--dart-define=TATLOCK_API_URL=https://tatlock.schweitz.net
# Stage 2: Serve with nginx
FROM nginx:alpine
+4 -1
View File
@@ -24,10 +24,13 @@ android {
applicationId = "net.schweitz.tatlock_ui"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 23 // Required for AppAuth
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
// flutter_appauth redirect scheme for OIDC callbacks
manifestPlaceholders["appAuthRedirectScheme"] = "net.schweitz.tatlock"
}
buildTypes {
+90 -1
View File
@@ -20,6 +20,7 @@ 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
@@ -142,7 +143,7 @@ part 'container_model.freezed.dart';
part 'container_model.g.dart';
@freezed
class ContainerModel with _$ContainerModel {
sealed class ContainerModel with _$ContainerModel {
const factory ContainerModel({
required String id,
required String name,
@@ -420,6 +421,41 @@ 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 |
@@ -464,6 +500,34 @@ Generated files:
- `*.freezed.dart` - Immutable classes
- `*.g.dart` - JSON serialization, Riverpod providers
### Freezed 3.x: Required `sealed class`
**Freezed 3.x requires the `sealed` keyword** on all classes with generated mixins. Without it, the generated code will fail to compile with errors about missing concrete implementations.
```dart
// ✅ Correct - Freezed 3.x
@freezed
sealed class UserModel with _$UserModel {
const factory UserModel({
required String id,
required String name,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
// ❌ Wrong - will fail to compile
@freezed
class UserModel with _$UserModel { // Missing `sealed`
const factory UserModel({...}) = _UserModel;
}
```
The `sealed` keyword was introduced in Dart 3.0 and allows the generated mixin `_$UserModel` to have abstract members that are implemented by the private `_UserModel` class.
**Always use `sealed class` with `@freezed`** - this applies to all models, entities, and state classes using Freezed.
## Import Rules
1. Never import from `data/` in `domain/`
@@ -481,3 +545,28 @@ 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
@@ -0,0 +1,368 @@
# 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
+13
View File
@@ -45,5 +45,18 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>net.schweitz.tatlock</string>
<key>CFBundleURLSchemes</key>
<array>
<string>net.schweitz.tatlock</string>
</array>
</dict>
</array>
</dict>
</plist>
+26 -22
View File
@@ -2,24 +2,28 @@ 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;
part 'api_client.g.dart';
/// Provides the Dio instance for Core API.
@riverpod
@persistentRiverpod
Dio coreApiClient(Ref ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.coreApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
final options = BaseOptions(
baseUrl: AppConfig.coreApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
final dio = platform.createDio(options);
dio.interceptors.addAll([
AuthInterceptor(ref),
LoggingInterceptor(),
@@ -30,20 +34,20 @@ Dio coreApiClient(Ref ref) {
}
/// Provides the Dio instance for Tatlock API.
@riverpod
@persistentRiverpod
Dio tatlockApiClient(Ref ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
final options = BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
final dio = platform.createDio(options);
dio.interceptors.addAll([
AuthInterceptor(ref),
LoggingInterceptor(),
+6
View File
@@ -0,0 +1,6 @@
import 'package:dio/dio.dart';
/// Create a Dio instance for native platforms (mobile, desktop).
Dio createDio(BaseOptions options) {
return Dio(options);
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:dio/dio.dart';
import 'package:dio_web_adapter/dio_web_adapter.dart';
/// Create a Dio instance for web platform with credentials support.
///
/// Enables `withCredentials` to send cookies with requests, which is
/// required for NPM forward auth to work correctly.
Dio createDio(BaseOptions options) {
final dio = Dio(options);
dio.httpClientAdapter = BrowserHttpClientAdapter(withCredentials: true);
return dio;
}
+6 -11
View File
@@ -9,7 +9,8 @@ import 'package:tatlock_ui/core/error/app_exception.dart';
/// Adds authentication token to requests.
///
/// Skipped entirely when [AppConfig.requiresAuth] is false (LAN development).
/// - **LAN mode**: Skipped entirely (no auth required)
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._ref);
@@ -23,6 +24,7 @@ class AuthInterceptor extends Interceptor {
return;
}
// Add Bearer token for all platforms (web + mobile)
final authState = _ref.read(authProvider);
authState.whenData((auth) {
@@ -36,16 +38,9 @@ class AuthInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 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();
}
// 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).
handler.next(err);
}
}
+288
View File
@@ -0,0 +1,288 @@
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);
}
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client.dart';
import 'permissions.dart';
import 'user_preferences.dart';
part 'auth_datasource.g.dart';
/// Response from POST /auth/sync endpoint.
class AuthSyncResponse {
const AuthSyncResponse({
required this.userId,
required this.authentikId,
required this.email,
required this.name,
this.avatarUrl,
required this.roles,
required this.preferences,
required this.isNewUser,
});
final String userId;
final String authentikId;
final String email;
final String name;
final String? avatarUrl;
final List<Role> roles;
final UserPreferences preferences;
final bool isNewUser;
factory AuthSyncResponse.fromJson(Map<String, dynamic> json) {
final user = json['user'] as Map<String, dynamic>;
final rolesJson = json['roles'] as List<dynamic>;
final prefsJson = json['preferences'] as Map<String, dynamic>;
return AuthSyncResponse(
userId: user['id'] as String,
authentikId: user['authentik_id'] as String,
email: user['email'] as String,
name: user['name'] as String,
avatarUrl: user['avatar_url'] as String?,
roles: rolesJson.map((r) => _parseRole(r as Map<String, dynamic>)).toList(),
preferences: UserPreferences.fromJson(prefsJson),
isNewUser: json['is_new_user'] as bool,
);
}
}
/// Parse a role from API JSON.
Role _parseRole(Map<String, dynamic> json) {
final name = json['name'] as String;
final domainStr = json['domain'] as String;
final category = json['category'] as String? ?? 'general';
final actionStr = json['action'] as String;
final domain = Domain.fromString(domainStr);
final action = Action.fromString(actionStr);
if (domain == null || action == null) {
// Return a placeholder role for unknown domains/actions
return Role(
id: json['id'] as String,
name: name,
domain: Domain.admin, // Fallback
category: category,
action: Action.viewer, // Fallback - least privilege
);
}
return Role(
id: json['id'] as String,
name: name,
domain: domain,
category: category,
action: action,
);
}
/// Datasource for auth API endpoints.
class AuthDatasource {
AuthDatasource(this._dio);
final Dio _dio;
/// Sync user with core-api after OIDC authentication.
///
/// Sends the OIDC access token to core-api, which validates it with Authentik
/// and returns the user profile, roles, and preferences.
Future<AuthSyncResponse> syncUser(String accessToken) async {
final response = await _dio.post<Map<String, dynamic>>(
'/auth/sync',
data: {'access_token': accessToken},
);
return AuthSyncResponse.fromJson(response.data!);
}
/// Get current user profile via NPM forward auth.
///
/// This endpoint reads X-authentik-* headers set by NPM forward auth.
/// Returns user profile if authenticated via the proxy.
/// Throws 401 if not authenticated or accessing directly.
Future<AuthSyncResponse> getCurrentUser() async {
final response = await _dio.get<Map<String, dynamic>>('/auth/users/me');
return AuthSyncResponse.fromJson(response.data!);
}
/// Update user preferences.
Future<UserPreferences> updatePreferences({
String? theme,
String? defaultRoom,
Map<String, dynamic>? preferencesJson,
}) async {
final data = <String, dynamic>{};
if (theme != null) data['theme'] = theme;
if (defaultRoom != null) data['default_room'] = defaultRoom;
if (preferencesJson != null) data['preferences_json'] = preferencesJson;
final response = await _dio.patch<Map<String, dynamic>>(
'/auth/users/me/preferences',
data: data,
);
return UserPreferences.fromJson(response.data!);
}
}
/// Provider for the auth datasource.
@riverpod
AuthDatasource authDatasource(Ref ref) {
return AuthDatasource(ref.watch(coreApiClientProvider));
}
+330 -23
View File
@@ -1,28 +1,49 @@
import 'dart:convert' show jsonDecode, jsonEncode;
import 'dart:developer' as developer;
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.
///
/// Note: Full OIDC implementation with flutter_appauth requires
/// native platform configuration. For now, this provides the
/// state management infrastructure.
@riverpod
/// 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
///
/// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences.
@persistentRiverpod
class AuthNotifier extends _$AuthNotifier {
// Storage keys
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';
@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.
return _loadStoredAuth();
}
@@ -40,17 +61,36 @@ class AuthNotifier extends _$AuthNotifier {
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
: null;
// Load roles from JSON
final rolesJson = prefs.getString(_rolesKey);
final roles = rolesJson != null ? _parseRoles(rolesJson) : <Role>[];
// Load preferences from JSON
final prefsJson = prefs.getString(_preferencesKey);
final preferences = prefsJson != null
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
: null;
final authState = 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,
);
// Check if token is expired
// Check if token is expired - try to refresh
if (authState.isTokenExpired && authState.refreshToken != null) {
developer.log('Token expired, attempting refresh', name: 'auth');
return _tryRefreshToken(authState);
}
if (authState.isTokenExpired) {
developer.log('Stored token expired, clearing auth', name: 'auth');
await _clearStoredAuth();
@@ -65,29 +105,285 @@ class AuthNotifier extends _$AuthNotifier {
}
}
/// Sign in with OIDC (placeholder for flutter_appauth integration).
/// Parse roles from stored JSON.
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) {
developer.log('Failed to parse roles: $e', name: 'auth');
return [];
}
}
/// Try to refresh the access token.
Future<AuthState> _tryRefreshToken(AuthState currentState) async {
if (currentState.refreshToken == null) {
await _clearStoredAuth();
return const AuthState();
}
try {
final oidcService = ref.read(oidcServiceProvider);
final tokens = await oidcService.refreshToken(currentState.refreshToken!);
// Update stored tokens
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accessTokenKey, tokens.accessToken);
if (tokens.refreshToken != null) {
await prefs.setString(_refreshTokenKey, tokens.refreshToken!);
}
await prefs.setInt(_expiresAtKey, tokens.expiresAt.millisecondsSinceEpoch);
developer.log('Token refreshed successfully', name: 'auth');
return currentState.copyWith(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? currentState.refreshToken,
expiresAt: tokens.expiresAt,
);
} catch (e) {
developer.log('Token refresh failed: $e', name: 'auth');
await _clearStoredAuth();
return const AuthState();
}
}
/// Sign in with the appropriate method for the platform.
///
/// - **Web**: Redirects to Authentik for OIDC authentication
/// - **Mobile**: Opens Authentik login via OIDC, then syncs with core-api
Future<void> signIn() async {
// TODO: Implement OIDC flow with flutter_appauth
// For now, this is a placeholder that will be implemented
// when native platform configuration is complete.
developer.log('Sign in requested - OIDC not yet configured', name: 'auth');
if (!AppConfig.requiresAuth) {
developer.log('Auth not required in LAN mode', name: 'auth');
// In LAN mode, set a minimal authenticated state
state = const AsyncData(AuthState(isAuthenticated: true));
return;
}
// Web: Use OIDC flow with browser redirect
if (kIsWeb) {
developer.log('Web sign-in: starting OIDC flow', name: 'auth');
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);
}
return;
}
// Mobile: Use OIDC flow with flutter_appauth
state = const AsyncLoading();
try {
// Step 1: OIDC authentication with Authentik
developer.log('Starting OIDC authentication', name: 'auth');
final oidcService = ref.read(oidcServiceProvider);
final tokens = await oidcService.signIn();
// 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);
// Step 3: Store credentials and user data
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} with ${syncResponse.roles.length} roles',
name: 'auth',
);
} on OidcException catch (e) {
developer.log('OIDC authentication failed: $e', name: 'auth');
state = AsyncError(e, StackTrace.current);
} catch (e, stack) {
developer.log('Authentication failed: $e', name: 'auth');
state = AsyncError(e, stack);
}
}
/// 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', name: 'auth');
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('/');
}
}
}
/// Update auth state (called after successful OIDC flow).
Future<void> setAuthenticated({
/// Update user preferences.
Future<void> updatePreferences({
String? theme,
String? defaultRoom,
Map<String, dynamic>? preferencesJson,
}) async {
final currentState = state.value;
if (currentState == null || !currentState.isAuthenticated) return;
try {
final authDatasource = ref.read(authDatasourceProvider);
final newPrefs = await authDatasource.updatePreferences(
theme: theme,
defaultRoom: defaultRoom,
preferencesJson: preferencesJson,
);
// Update stored preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_preferencesKey, jsonEncode(newPrefs.toJson()));
state = AsyncData(currentState.copyWith(preferences: newPrefs));
developer.log('Preferences updated', name: 'auth');
} catch (e) {
developer.log('Failed to update preferences: $e', name: 'auth');
rethrow;
}
}
/// Store authentication data to SharedPreferences.
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();
@@ -99,20 +395,27 @@ class AuthNotifier extends _$AuthNotifier {
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);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
userId: userId,
userName: userName,
userEmail: userEmail,
));
// Store roles as JSON
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);
}
developer.log('Authenticated as $userName', name: 'auth');
// Store preferences as JSON
if (preferences != null) {
await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson()));
}
}
Future<void> _clearStoredAuth() async {
@@ -121,7 +424,11 @@ class AuthNotifier extends _$AuthNotifier {
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);
}
}
+37 -1
View File
@@ -1,18 +1,46 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'permissions.dart';
import 'user_preferences.dart';
part 'auth_state.freezed.dart';
/// Authentication state.
/// Authentication state including user profile, roles, and preferences.
@freezed
sealed class AuthState with _$AuthState {
const factory AuthState({
/// Whether user is authenticated.
@Default(false) bool isAuthenticated,
/// OIDC access token.
String? accessToken,
/// OIDC refresh token.
String? refreshToken,
/// Token expiration time.
DateTime? expiresAt,
/// Internal user ID (from core-api).
String? userId,
/// Authentik user ID.
String? authentikId,
/// User display name.
String? userName,
/// User email address.
String? userEmail,
/// User avatar URL.
String? avatarUrl,
/// User's permission roles.
@Default([]) List<Role> roles,
/// User preferences.
UserPreferences? preferences,
}) = _AuthState;
const AuthState._();
@@ -23,4 +51,12 @@ sealed class AuthState with _$AuthState {
// Consider expired if less than 1 minute remaining
return DateTime.now().isAfter(expiresAt!.subtract(const Duration(minutes: 1)));
}
/// Check if user has the specified permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
return roles.hasPermission(domain, action, category: category);
}
/// Check if user is a global admin.
bool get isGlobalAdmin => roles.isGlobalAdmin;
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
part 'oidc_service.g.dart';
/// OIDC token response containing access and refresh tokens.
class OidcTokens {
const OidcTokens({
required this.accessToken,
required this.refreshToken,
required this.expiresAt,
this.idToken,
});
final String accessToken;
final String? refreshToken;
final DateTime expiresAt;
final String? idToken;
}
/// Service for OIDC authentication using flutter_appauth.
///
/// Handles the Authorization Code flow with PKCE for secure authentication
/// against Authentik.
class OidcService {
OidcService({FlutterAppAuth? appAuth}) : _appAuth = appAuth ?? const FlutterAppAuth();
final FlutterAppAuth _appAuth;
/// OIDC scopes to request.
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
/// Redirect URI for the app.
static String get _redirectUri => '${AppConfig.authRedirectScheme}://callback';
/// Start the authorization code flow.
///
/// Opens a browser/webview for user to authenticate with Authentik,
/// then exchanges the authorization code for tokens.
///
/// Throws [OidcException] if authentication fails.
Future<OidcTokens> signIn() async {
try {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
AppConfig.authClientId,
_redirectUri,
discoveryUrl: AppConfig.authDiscoveryUrl,
scopes: _scopes,
),
);
if (result.accessToken == null) {
throw OidcException('Authorization failed: no access token');
}
return OidcTokens(
accessToken: result.accessToken!,
refreshToken: result.refreshToken,
expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)),
idToken: result.idToken,
);
} on Exception catch (e) {
throw OidcException('Authorization failed: $e');
}
}
/// Refresh the access token using a refresh token.
///
/// Throws [OidcException] if refresh fails.
Future<OidcTokens> refreshToken(String refreshToken) async {
try {
final result = await _appAuth.token(
TokenRequest(
AppConfig.authClientId,
_redirectUri,
discoveryUrl: AppConfig.authDiscoveryUrl,
refreshToken: refreshToken,
scopes: _scopes,
),
);
if (result.accessToken == null) {
throw OidcException('Token refresh failed: no access token');
}
return OidcTokens(
accessToken: result.accessToken!,
refreshToken: result.refreshToken ?? refreshToken,
expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)),
idToken: result.idToken,
);
} on Exception catch (e) {
throw OidcException('Token refresh failed: $e');
}
}
}
/// Exception thrown when OIDC operations fail.
class OidcException implements Exception {
const OidcException(this.message);
final String message;
@override
String toString() => 'OidcException: $message';
}
/// Provider for the OIDC service.
@riverpod
OidcService oidcService(Ref ref) {
return OidcService();
}
+245
View File
@@ -0,0 +1,245 @@
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();
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/widgets.dart' hide Action;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'permissions.dart';
/// A widget that conditionally renders its child based on user permissions.
///
/// Example:
/// ```dart
/// PermissionGate(
/// domain: Domain.controlRoom,
/// action: Action.admin,
/// child: DeleteButton(),
/// fallback: Text('No permission'),
/// )
/// ```
class PermissionGate extends ConsumerWidget {
const PermissionGate({
super.key,
required this.domain,
required this.action,
this.category = 'general',
required this.child,
this.fallback,
});
/// The domain required for this permission.
final Domain domain;
/// The action level required (viewer, user, editor, admin).
final Action action;
/// Optional category within the domain (defaults to 'general').
final String category;
/// Widget to show when user has permission.
final Widget child;
/// Widget to show when user lacks permission (defaults to empty).
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final hasPermission = authState.maybeWhen(
data: (state) => state.hasPermission(domain, action, category: category),
orElse: () => false,
);
if (hasPermission) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}
/// A widget that shows its child only if the user is a global admin.
class AdminGate extends ConsumerWidget {
const AdminGate({
super.key,
required this.child,
this.fallback,
});
/// Widget to show when user is admin.
final Widget child;
/// Widget to show when user is not admin (defaults to empty).
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final isAdmin = authState.maybeWhen(
data: (state) => state.isGlobalAdmin,
orElse: () => false,
);
if (isAdmin) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}
/// Extension for checking permissions in code.
extension PermissionCheck on WidgetRef {
/// Check if the current user has a specific permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
final authState = read(authProvider);
return authState.maybeWhen(
data: (state) => state.hasPermission(domain, action, category: category),
orElse: () => false,
);
}
/// Check if the current user is a global admin.
bool get isGlobalAdmin {
final authState = read(authProvider);
return authState.maybeWhen(
data: (state) => state.isGlobalAdmin,
orElse: () => false,
);
}
}
+137
View File
@@ -0,0 +1,137 @@
// Permission system for role-based access control.
//
// Roles follow the format: `domain.category:action`
// - Domain: Feature area (control-room, media, etc.)
// - Category: Sub-area within domain (default: general)
// - Action: Permission level (viewer < user < editor < admin)
/// Permission domains matching feature areas.
enum Domain {
controlRoom('control-room'),
library('library'),
media('media'),
ai('ai'),
housekeeper('housekeeper'),
developer('developer'),
documents('documents'),
gaming('gaming'),
admin('admin');
const Domain(this.value);
/// The API string value for this domain.
final String value;
/// Parse a domain string from API response.
static Domain? fromString(String value) {
for (final domain in Domain.values) {
if (domain.value == value) return domain;
}
return null;
}
}
/// Permission actions in hierarchical order.
///
/// Higher actions imply lower ones:
/// - admin implies editor, user, viewer
/// - editor implies user, viewer
/// - user implies viewer
enum Action {
viewer(1),
user(2),
editor(3),
admin(4);
const Action(this.level);
/// Numeric level for comparison (higher = more permissions).
final int level;
/// Check if this action grants at least the required action.
bool grants(Action required) => level >= required.level;
/// Parse an action string from API response.
static Action? fromString(String value) {
for (final action in Action.values) {
if (action.name == value) return action;
}
return null;
}
}
/// A permission role assigned to a user.
///
/// Roles are parsed from the API format: `domain.category:action`
class Role {
const Role({
required this.id,
required this.name,
required this.domain,
required this.category,
required this.action,
});
/// Unique role ID.
final String id;
/// Full role name (e.g., "control-room.general:admin").
final String name;
/// Permission domain.
final Domain domain;
/// Permission category (usually "general").
final String category;
/// Permission action level.
final Action action;
/// Check if this role grants access for the given domain and action.
///
/// Global admin (`admin.general:admin`) grants access to everything.
/// Otherwise, domain and category must match, and action level must be sufficient.
bool grants(Domain domain, Action action, {String category = 'general'}) {
// Global admin override
if (this.domain == Domain.admin &&
this.category == 'general' &&
this.action == Action.admin) {
return true;
}
// Check domain and category match
if (this.domain != domain || this.category != category) {
return false;
}
// Check action hierarchy
return this.action.grants(action);
}
@override
String toString() => 'Role($name)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Role && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
/// Extension for checking permissions on a list of roles.
extension RoleListPermissions on List<Role> {
/// Check if any role grants the required permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
return any((role) => role.grants(domain, action, category: category));
}
/// Check if any role grants any of the required permissions.
bool hasAnyPermission(List<(Domain, Action)> permissions) {
return permissions.any((p) => hasPermission(p.$1, p.$2));
}
/// Check if user is a global admin.
bool get isGlobalAdmin => hasPermission(Domain.admin, Action.admin);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_preferences.freezed.dart';
part 'user_preferences.g.dart';
/// User preferences synced from core-api.
@freezed
sealed class UserPreferences with _$UserPreferences {
const factory UserPreferences({
/// Theme preference: system, light, dark
@Default('system') String theme,
/// Default room for housekeeping
@Default('front-hall') String defaultRoom,
/// Extended preferences as JSON
@Default({}) Map<String, dynamic> preferencesJson,
}) = _UserPreferences;
factory UserPreferences.fromJson(Map<String, dynamic> json) =>
_$UserPreferencesFromJson(json);
}
+7
View File
@@ -0,0 +1,7 @@
/// 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
@@ -0,0 +1,32 @@
/// 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
@@ -0,0 +1,34 @@
/// 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);
}
+7 -1
View File
@@ -45,12 +45,18 @@ class AppConfig {
defaultValue: 'tatlock-ui',
);
/// Authentik redirect URI scheme
/// Authentik redirect URI scheme (for mobile/native)
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
@@ -0,0 +1,5 @@
/// 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
@@ -0,0 +1,4 @@
/// Stub for non-web platforms - does nothing.
void configureUrlStrategy() {
// No-op on mobile/desktop
}
+10
View File
@@ -0,0 +1,10 @@
/// 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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,125 @@
/// 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
@@ -0,0 +1,118 @@
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,
);
}
}
+43 -1
View File
@@ -1,6 +1,10 @@
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';
@@ -17,13 +21,21 @@ enum ThemeSetting {
}
/// Provider for theme setting state.
@riverpod
///
/// Syncs with API preferences when user is authenticated. On login, the theme
/// from API preferences takes precedence over local storage.
@persistentRiverpod
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;
}
@@ -39,6 +51,36 @@ 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,9 +1,13 @@
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';
@@ -101,7 +105,10 @@ class ContainerPort {
/// Page displaying the list of containers using DataGrid.
class ContainersListPage extends ConsumerStatefulWidget {
const ContainersListPage({super.key});
const ContainersListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<ContainersListPage> createState() => _ContainersListPageState();
@@ -110,10 +117,18 @@ 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,
@@ -121,17 +136,68 @@ 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,
@@ -140,12 +206,14 @@ 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),
@@ -211,6 +279,15 @@ 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,8 +1,12 @@
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';
@@ -33,7 +37,10 @@ class DomainData {
/// Page displaying the list of proxy hosts (domains) from NPM.
class ProxyHostsPage extends ConsumerStatefulWidget {
const ProxyHostsPage({super.key});
const ProxyHostsPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<ProxyHostsPage> createState() => _ProxyHostsPageState();
@@ -42,10 +49,18 @@ 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,
@@ -53,17 +68,80 @@ 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,
@@ -72,12 +150,14 @@ 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),
@@ -101,6 +181,8 @@ 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() {
@@ -112,6 +194,15 @@ 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(
@@ -128,6 +219,8 @@ 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,11 +17,15 @@ 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;
@@ -53,7 +57,7 @@ class ControlRoomPage extends ConsumerWidget {
),
// Section content
Expanded(
child: _SectionContent(nav: nav),
child: _SectionContent(nav: nav, routerState: routerState),
),
],
),
@@ -63,50 +67,18 @@ class ControlRoomPage extends ConsumerWidget {
/// Renders content for the selected nav item.
class _SectionContent extends ConsumerWidget {
const _SectionContent({required this.nav});
const _SectionContent({required this.nav, this.routerState});
final ControlRoomNav nav;
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
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),
),
],
);
return switch (nav) {
ControlRoomNav.containers => _ContainersSection(routerState: routerState),
ControlRoomNav.proxyHosts => ProxyHostsPage(routerState: routerState),
_ => _PlaceholderSection(nav: nav),
};
}
}
@@ -150,9 +122,43 @@ 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();
const _StacksFilterPanel({this.routerState});
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -210,6 +216,7 @@ class _StacksFilterPanel extends ConsumerWidget {
data: (stacks) => _StacksList(
stacks: stacks,
selectedStackId: selectedStack,
routerState: routerState,
onStackSelected: (id) =>
ref.read(selectedStackProvider.notifier).select(id),
onStackAction: (id, action) =>
@@ -284,12 +291,14 @@ 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();
+20 -19
View File
@@ -1,35 +1,32 @@
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';
// Portainer
// Stack
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';
static const redirections = '/control-room/redirections';
static const streams = '/control-room/streams';
static const certificates = '/control-room/certificates';
// 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';
}
/// Control Room navigation items with section grouping.
enum ControlRoomNav {
// 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');
// 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');
const ControlRoomNav(this.id, this.label, this.icon, this.section);
@@ -66,7 +63,11 @@ List<RouteBase> controlRoomRoutes() {
GoRoute(
path: nav.path,
name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}',
builder: (context, state) => ControlRoomPage(nav: nav),
pageBuilder: (context, state) => noTransitionPage(
context,
state,
ControlRoomPage(nav: nav, routerState: state),
),
),
];
}
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/front_hall/data/models/system_stats_model.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/system_stats_provider.dart';
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
import 'package:tatlock_ui/shared/widgets/widgets.dart';
import 'package:tatlock_ui/version.g.dart';
@@ -197,18 +198,6 @@ class _SystemStatsCard extends StatelessWidget {
final SystemStats stats;
/// Pastel colors based on usage percentage.
static const _pastelGreen = Color(0xFF81C784);
static const _pastelOrange = Color(0xFFFFB74D);
static const _pastelRed = Color(0xFFE57373);
/// Returns color based on usage: green ≤50%, orange 51-75%, red >75%.
Color _colorForUsage(double percent) {
if (percent <= 50) return _pastelGreen;
if (percent <= 75) return _pastelOrange;
return _pastelRed;
}
@override
Widget build(BuildContext context) {
// Build gauges list: CPU, Memory, GPU (if available), then all disks
@@ -217,20 +206,20 @@ class _SystemStatsCard extends StatelessWidget {
value: stats.cpu.usagePercent / 100,
label: 'CPU',
icon: Icons.memory,
color: _colorForUsage(stats.cpu.usagePercent),
color: StoplightColors.forPercent(stats.cpu.usagePercent),
),
GaugeData(
value: stats.memory.usagePercent / 100,
label: 'RAM',
icon: Icons.storage,
color: _colorForUsage(stats.memory.usagePercent),
color: StoplightColors.forPercent(stats.memory.usagePercent),
),
if (stats.gpu.available && stats.gpu.usagePercent != null)
GaugeData(
value: stats.gpu.usagePercent! / 100,
label: 'VRAM',
icon: Icons.videocam,
color: _colorForUsage(stats.gpu.usagePercent!),
color: StoplightColors.forPercent(stats.gpu.usagePercent!),
),
// Add a gauge for each disk
...stats.disks.map(
@@ -238,7 +227,7 @@ class _SystemStatsCard extends StatelessWidget {
value: disk.usagePercent / 100,
label: _formatDiskLabel(disk),
icon: Icons.disc_full,
color: _colorForUsage(disk.usagePercent),
color: StoplightColors.forPercent(disk.usagePercent),
),
),
];
@@ -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 html.IFrameElement _iframe;
late web.HTMLIFrameElement _iframe;
bool _isLoading = true;
@override
@@ -42,17 +42,18 @@ class _IframeViewState extends State<IframeView> {
}
void _createIframe() {
_iframe = html.IFrameElement()
_iframe = web.document.createElement('iframe') as web.HTMLIFrameElement
..src = widget.url
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..allow = 'fullscreen'
..onLoad.listen((_) {
if (mounted) {
setState(() => _isLoading = false);
}
});
..allow = 'fullscreen';
_iframe.onLoad.listen((_) {
if (mounted) {
setState(() => _isLoading = false);
}
});
// Register the view factory
ui_web.platformViewRegistry.registerViewFactory(
@@ -1,6 +1,10 @@
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';
@@ -31,7 +35,10 @@ class GroupData {
/// Groups list page using the shared DataGrid component.
class GroupsListPage extends ConsumerStatefulWidget {
const GroupsListPage({super.key});
const GroupsListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<GroupsListPage> createState() => _GroupsListPageState();
@@ -40,11 +47,19 @@ 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>(
@@ -53,13 +68,63 @@ 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);
@@ -96,6 +161,7 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
return DataGridConfig<GroupData>(
columns: [
DataGridColumn<GroupData>(
id: 'name',
header: 'Name',
valueBuilder: (g) => g.name,
sortable: true,
@@ -103,18 +169,21 @@ 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),
@@ -129,6 +198,15 @@ 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,11 +11,15 @@ 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;
@@ -45,7 +49,7 @@ class SecurityPage extends ConsumerWidget {
),
// Section content
Expanded(
child: _SectionContent(nav: nav),
child: _SectionContent(nav: nav, routerState: routerState),
),
],
),
@@ -55,17 +59,18 @@ class SecurityPage extends ConsumerWidget {
/// Renders content for the selected nav item.
class _SectionContent extends StatelessWidget {
const _SectionContent({required this.nav});
const _SectionContent({required this.nav, this.routerState});
final SecurityNav nav;
final GoRouterState? routerState;
@override
Widget build(BuildContext context) {
switch (nav) {
case SecurityNav.users:
return const UsersListPage();
return UsersListPage(routerState: routerState);
case SecurityNav.groups:
return const GroupsListPage();
return GroupsListPage(routerState: routerState);
default:
return _PlaceholderSection(nav: nav);
}
+6 -1
View File
@@ -1,6 +1,7 @@
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.
@@ -60,7 +61,11 @@ List<RouteBase> securityRoutes() {
GoRoute(
path: nav.path,
name: 'security${_capitalize(nav.id.replaceAll('-', '_'))}',
builder: (context, state) => SecurityPage(nav: nav),
pageBuilder: (context, state) => noTransitionPage(
context,
state,
SecurityPage(nav: nav, routerState: state),
),
),
];
}
@@ -1,6 +1,10 @@
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';
@@ -61,7 +65,10 @@ class UserData {
/// Users list page using the shared DataGrid component.
class UsersListPage extends ConsumerStatefulWidget {
const UsersListPage({super.key});
const UsersListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<UsersListPage> createState() => _UsersListPageState();
@@ -70,11 +77,19 @@ 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,
@@ -82,13 +97,63 @@ 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);
@@ -125,6 +190,7 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
return DataGridConfig<UserData>(
columns: [
DataGridColumn<UserData>(
id: 'user',
header: 'User',
valueBuilder: (u) => u.name,
sortable: true,
@@ -133,12 +199,14 @@ 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),
@@ -154,6 +222,15 @@ 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(),
@@ -0,0 +1,306 @@
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),
],
),
),
],
),
);
}
}
+25 -10
View File
@@ -1,22 +1,37 @@
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() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
developer.log(
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
// Enable semantics tree on web for accessibility and automation tools.
// This exposes widget identifiers to browser automation (Puppeteer, etc.)
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
runApp(
const ProviderScope(
child: TatlockApp(),
),
);
// 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()));
}
+46 -7
View File
@@ -1,48 +1,86 @@
import 'package:flutter/material.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/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 root = '/';
static const frontHall = '/front-hall';
static const parlor = '/parlor';
static const settings = '/settings';
}
/// Maps defaultRoom preference value to route path.
String _routeForDefaultRoom(String? defaultRoom) {
return switch (defaultRoom) {
'control-room' => ControlRoomRoutes.containers,
'security' => SecurityRoutes.users,
'parlor' => AppRoutes.parlor,
_ => AppRoutes.frontHall,
};
}
/// Provides the GoRouter instance.
@riverpod
GoRouter appRouter(Ref ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: AppRoutes.frontHall,
initialLocation: AppRoutes.root,
debugLogDiagnostics: true,
redirect: (context, state) {
// Redirect from root to user's default room preference
if (state.matchedLocation == AppRoutes.root) {
final defaultRoom = authState.value?.preferences?.defaultRoom;
return _routeForDefaultRoom(defaultRoom);
}
return null;
},
routes: [
// Root redirects to default room (handled by redirect callback above)
GoRoute(
path: AppRoutes.root,
redirect: (context, state) => AppRoutes.frontHall,
),
// Main app routes (inside shell with app scaffold)
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
routes: [
GoRoute(
path: AppRoutes.frontHall,
name: 'frontHall',
builder: (context, state) => const FrontHallPage(),
pageBuilder: (context, state) =>
noTransitionPage(context, state, const FrontHallPage()),
),
...controlRoomRoutes(),
...securityRoutes(),
GoRoute(
path: AppRoutes.parlor,
name: 'parlor',
builder: (context, state) =>
const _PlaceholderPage(title: 'Parlor'),
pageBuilder: (context, state) =>
noTransitionPage(context, state, const _PlaceholderPage(title: 'Parlor')),
),
GoRoute(
path: AppRoutes.settings,
name: 'settings',
builder: (context, state) =>
const _PlaceholderPage(title: 'Settings'),
pageBuilder: (context, state) =>
noTransitionPage(context, state, const SettingsPage()),
),
],
),
@@ -84,3 +122,4 @@ class _PlaceholderPage extends StatelessWidget {
);
}
}
+98
View File
@@ -0,0 +1,98 @@
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
@@ -0,0 +1,4 @@
/// Stub implementation for non-web platforms.
void updateBrowserUrlParams(Map<String, String> params) {
// No-op on non-web platforms
}
+13
View File
@@ -0,0 +1,13 @@
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,6 +45,7 @@ enum DataGridColumnAlignment {
/// Column definition for DataGrid.
class DataGridColumn<T> {
const DataGridColumn({
required this.id,
required this.header,
required this.valueBuilder,
this.cellBuilder,
@@ -58,6 +59,9 @@ 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,9 +15,15 @@ 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>(
sortColumnIndex: config.defaultSortColumn,
sortDescending: config.defaultSortDescending,
searchQuery: initialSearch ?? '',
sortColumnIndex:
initialSortColumnIndex ?? config.defaultSortColumn,
sortDescending:
initialSortDescending ?? config.defaultSortDescending,
)) {
// Initial load
_load();
@@ -29,8 +35,13 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// Grid configuration.
final DataGridConfig<T> config;
/// Function to extract unique ID from an item.
final Object Function(T item) idSelector;
/// 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;
/// Debounce timer for search.
Timer? _searchDebounce;
@@ -185,7 +196,7 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
if (!config.rowsSelectable) return;
final id = idSelector(item);
final newSelection = Set<Object>.from(state.selectedIds);
final newSelection = Set<String>.from(state.selectedIds);
if (newSelection.contains(id)) {
newSelection.remove(id);
@@ -287,17 +298,35 @@ 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 Object Function(T) idSelector,
required String Function(T) idSelector,
String? initialSearch,
int? initialSortColumnIndex,
bool? initialSortDescending,
}) {
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<Object> selectedIds,
@Default({}) Set<String> selectedIds,
/// Current page (for paginated mode).
@Default(0) int currentPage,
+166 -2
View File
@@ -1,5 +1,9 @@
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';
@@ -7,6 +11,11 @@ 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:
/// ```
/// ┌─────────────────────────────────────────────────────────────────┐
@@ -15,16 +24,62 @@ import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart';
/// │ BODY: Room page content (may include room-specific sidebar) │
/// └─────────────────────────────────────────────────────────────────┘
/// ```
class AppScaffold extends StatelessWidget {
class AppScaffold extends ConsumerStatefulWidget {
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: [
@@ -32,7 +87,7 @@ class AppScaffold extends StatelessWidget {
Positioned.fill(
child: Padding(
padding: const EdgeInsets.only(top: _headerHeight),
child: child,
child: widget.child,
),
),
@@ -51,6 +106,115 @@ class AppScaffold extends StatelessWidget {
);
}
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;
+17 -10
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'panel_header.dart';
@@ -199,17 +200,22 @@ class _NavTile extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
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,
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,
borderRadius: BorderRadius.circular(8),
child: Padding(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
@@ -258,6 +264,7 @@ class _NavTile extends StatelessWidget {
),
),
),
),
);
}
}
+216 -73
View File
@@ -1,7 +1,11 @@
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.
@@ -13,90 +17,213 @@ 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) => 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(
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,
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,
),
),
],
),
),
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(
itemBuilder: (context) => [
// User info header (non-selectable)
PopupMenuItem<String>(
enabled: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.logout, size: 20),
SizedBox(width: 12),
Text('Logout'),
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,
),
),
],
),
),
],
onSelected: (value) {
switch (value) {
case 'settings':
context.go(AppRoutes.settings);
case 'logout':
ref.read(authProvider.notifier).signOut();
}
},
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();
}
},
),
),
loading: () => const CircleAvatar(
radius: 18,
@@ -126,4 +253,20 @@ 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');
}
}
}
+18 -11
View File
@@ -1,6 +1,7 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'profile_dropdown.dart';
@@ -127,17 +128,23 @@ class TopHeaderBar extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
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: 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,
),
),
),
),
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
/// Pastel stoplight colors for consistent status indication across the app.
///
/// Use these colors for any green/yellow/red status flows:
/// - System stats (CPU, RAM, disk usage)
/// - Air quality levels
/// - Container health status
/// - Any other threshold-based indicators
abstract final class StoplightColors {
/// Good/healthy/low usage (0-50%)
static const green = Color(0xFF81C784);
/// Warning/moderate/medium usage (51-75%)
static const orange = Color(0xFFFFB74D);
/// Critical/unhealthy/high usage (76-100%)
static const red = Color(0xFFE57373);
/// Returns appropriate color based on percentage (0-100).
///
/// - ≤50%: green
/// - 51-75%: orange
/// - >75%: red
static Color forPercent(double percent) {
if (percent <= 50) return green;
if (percent <= 75) return orange;
return red;
}
/// Returns appropriate color based on value (0.0-1.0).
///
/// - ≤0.5: green
/// - 0.51-0.75: orange
/// - >0.75: red
static Color forValue(double value) => forPercent(value * 100);
}
+5 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
/// Air Quality Index widget displaying current AQI.
///
@@ -203,28 +204,28 @@ enum AqiLevel {
good(
label: 'Good',
description: 'Air quality is satisfactory',
color: Colors.green,
color: StoplightColors.green,
minIndex: 0,
maxIndex: 50,
),
moderate(
label: 'Moderate',
description: 'Acceptable for most people',
color: Colors.amber,
color: StoplightColors.orange,
minIndex: 51,
maxIndex: 100,
),
unhealthySensitive(
label: 'Unhealthy for Sensitive',
description: 'May affect sensitive groups',
color: Colors.orange,
color: StoplightColors.orange,
minIndex: 101,
maxIndex: 150,
),
unhealthy(
label: 'Unhealthy',
description: 'Health effects for everyone',
color: Colors.red,
color: StoplightColors.red,
minIndex: 151,
maxIndex: 200,
),
-13
View File
@@ -1,13 +0,0 @@
// 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
@@ -0,0 +1,53 @@
# 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;
}
+9 -1
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: 0.3.3+1
version: 1.3.1+1
environment:
sdk: ^3.10.4
@@ -30,6 +30,8 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
# State Management
flutter_riverpod: ^3.0.0
@@ -50,6 +52,11 @@ dependencies:
# Storage
shared_preferences: ^2.3.3
# Authentication (OIDC/OAuth2)
flutter_appauth: ^8.0.0
crypto: ^3.0.3
web: ^1.1.0
# UI
flex_color_scheme: ^8.1.0
flutter_adaptive_scaffold: ^0.3.1
@@ -64,6 +71,7 @@ dependencies:
url_launcher: ^6.3.1
flutter_code_editor: ^0.3.5
highlight: ^0.7.0
dio_web_adapter: ^2.1.1
dev_dependencies:
flutter_test:
+280
View File
@@ -0,0 +1,280 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/core/auth/auth_state.dart';
import 'package:tatlock_ui/core/auth/permissions.dart';
import 'package:tatlock_ui/core/auth/user_preferences.dart';
void main() {
group('AuthState', () {
group('default constructor', () {
test('creates unauthenticated state by default', () {
const state = AuthState();
expect(state.isAuthenticated, isFalse);
expect(state.accessToken, isNull);
expect(state.refreshToken, isNull);
expect(state.expiresAt, isNull);
expect(state.userId, isNull);
expect(state.userName, isNull);
expect(state.userEmail, isNull);
expect(state.roles, isEmpty);
expect(state.preferences, isNull);
});
});
group('authenticated state', () {
test('stores all user data', () {
final expiresAt = DateTime.now().add(const Duration(hours: 1));
const preferences = UserPreferences(
theme: 'dark',
defaultRoom: 'kitchen',
);
final state = AuthState(
isAuthenticated: true,
accessToken: 'access_token',
refreshToken: 'refresh_token',
expiresAt: expiresAt,
userId: 'user-123',
authentikId: 'authentik-456',
userName: 'Test User',
userEmail: 'test@example.com',
avatarUrl: 'https://example.com/avatar.jpg',
roles: const [
Role(
id: '1',
name: 'admin.general:admin',
domain: Domain.admin,
category: 'general',
action: Action.admin,
),
],
preferences: preferences,
);
expect(state.isAuthenticated, isTrue);
expect(state.accessToken, equals('access_token'));
expect(state.refreshToken, equals('refresh_token'));
expect(state.expiresAt, equals(expiresAt));
expect(state.userId, equals('user-123'));
expect(state.authentikId, equals('authentik-456'));
expect(state.userName, equals('Test User'));
expect(state.userEmail, equals('test@example.com'));
expect(state.avatarUrl, equals('https://example.com/avatar.jpg'));
expect(state.roles.length, equals(1));
expect(state.preferences?.theme, equals('dark'));
});
});
group('isTokenExpired', () {
test('returns true when expiresAt is null', () {
const state = AuthState(isAuthenticated: true);
expect(state.isTokenExpired, isTrue);
});
test('returns true when token is expired', () {
final expiredTime = DateTime.now().subtract(const Duration(hours: 1));
final state = AuthState(
isAuthenticated: true,
expiresAt: expiredTime,
);
expect(state.isTokenExpired, isTrue);
});
test('returns true when token expires within 1 minute', () {
final soonExpires = DateTime.now().add(const Duration(seconds: 30));
final state = AuthState(
isAuthenticated: true,
expiresAt: soonExpires,
);
expect(state.isTokenExpired, isTrue);
});
test('returns false when token is valid', () {
final futureExpires = DateTime.now().add(const Duration(hours: 1));
final state = AuthState(
isAuthenticated: true,
expiresAt: futureExpires,
);
expect(state.isTokenExpired, isFalse);
});
});
group('hasPermission', () {
test('returns true when role grants permission', () {
const state = AuthState(
isAuthenticated: true,
roles: [
Role(
id: '1',
name: 'control-room.general:admin',
domain: Domain.controlRoom,
category: 'general',
action: Action.admin,
),
],
);
expect(state.hasPermission(Domain.controlRoom, Action.admin), isTrue);
expect(state.hasPermission(Domain.controlRoom, Action.viewer), isTrue);
});
test('returns false when no role grants permission', () {
const state = AuthState(
isAuthenticated: true,
roles: [
Role(
id: '1',
name: 'media.general:viewer',
domain: Domain.media,
category: 'general',
action: Action.viewer,
),
],
);
expect(state.hasPermission(Domain.controlRoom, Action.viewer), isFalse);
expect(state.hasPermission(Domain.media, Action.admin), isFalse);
});
test('respects category parameter', () {
const state = AuthState(
isAuthenticated: true,
roles: [
Role(
id: '1',
name: 'control-room.servers:admin',
domain: Domain.controlRoom,
category: 'servers',
action: Action.admin,
),
],
);
expect(
state.hasPermission(
Domain.controlRoom,
Action.admin,
category: 'servers',
),
isTrue,
);
expect(
state.hasPermission(
Domain.controlRoom,
Action.admin,
category: 'general',
),
isFalse,
);
});
test('returns false for empty roles list', () {
const state = AuthState(isAuthenticated: true, roles: []);
expect(state.hasPermission(Domain.controlRoom, Action.viewer), isFalse);
});
});
group('isGlobalAdmin', () {
test('returns true when user has admin.general:admin role', () {
const state = AuthState(
isAuthenticated: true,
roles: [
Role(
id: '1',
name: 'admin.general:admin',
domain: Domain.admin,
category: 'general',
action: Action.admin,
),
],
);
expect(state.isGlobalAdmin, isTrue);
});
test('returns false when user lacks admin role', () {
const state = AuthState(
isAuthenticated: true,
roles: [
Role(
id: '1',
name: 'control-room.general:admin',
domain: Domain.controlRoom,
category: 'general',
action: Action.admin,
),
],
);
expect(state.isGlobalAdmin, isFalse);
});
test('returns false for empty roles list', () {
const state = AuthState(isAuthenticated: true, roles: []);
expect(state.isGlobalAdmin, isFalse);
});
});
group('copyWith', () {
test('creates a copy with modified values', () {
const original = AuthState(
isAuthenticated: true,
accessToken: 'old_token',
userName: 'Original User',
);
final updated = original.copyWith(
accessToken: 'new_token',
userName: 'Updated User',
);
// Original unchanged
expect(original.accessToken, equals('old_token'));
expect(original.userName, equals('Original User'));
// Updated has new values
expect(updated.accessToken, equals('new_token'));
expect(updated.userName, equals('Updated User'));
// Preserved unchanged values
expect(updated.isAuthenticated, equals(original.isAuthenticated));
});
});
group('equality', () {
test('two identical states are equal', () {
const state1 = AuthState(
isAuthenticated: true,
userId: 'user-123',
userName: 'Test User',
);
const state2 = AuthState(
isAuthenticated: true,
userId: 'user-123',
userName: 'Test User',
);
expect(state1, equals(state2));
});
test('different states are not equal', () {
const state1 = AuthState(
isAuthenticated: true,
userId: 'user-123',
);
const state2 = AuthState(
isAuthenticated: true,
userId: 'user-456',
);
expect(state1, isNot(equals(state2)));
});
});
});
}
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/core/auth/auth_state.dart';
import 'package:tatlock_ui/core/auth/permissions.dart';
// Note: PermissionGate widget tests require complex provider mocking.
// These unit tests verify the permission logic that PermissionGate relies on.
// Widget integration tests should be done with a running app or container testing.
void main() {
group('PermissionGate logic', () {
test('hasPermission returns true when role grants permission', () {
final state = AuthState(
isAuthenticated: true,
roles: const [
Role(
id: '1',
name: 'control-room.general:admin',
domain: Domain.controlRoom,
category: 'general',
action: Action.admin,
),
],
);
expect(state.hasPermission(Domain.controlRoom, Action.admin), isTrue);
expect(state.hasPermission(Domain.controlRoom, Action.viewer), isTrue);
});
test('hasPermission returns false when role does not grant permission', () {
final state = AuthState(
isAuthenticated: true,
roles: const [
Role(
id: '1',
name: 'media.general:viewer',
domain: Domain.media,
category: 'general',
action: Action.viewer,
),
],
);
expect(state.hasPermission(Domain.controlRoom, Action.admin), isFalse);
expect(state.hasPermission(Domain.media, Action.admin), isFalse);
});
test('hasPermission returns false when not authenticated', () {
const state = AuthState(isAuthenticated: false);
expect(state.hasPermission(Domain.controlRoom, Action.viewer), isFalse);
});
test('hasPermission respects category', () {
final state = AuthState(
isAuthenticated: true,
roles: const [
Role(
id: '1',
name: 'control-room.servers:admin',
domain: Domain.controlRoom,
category: 'servers',
action: Action.admin,
),
],
);
expect(
state.hasPermission(Domain.controlRoom, Action.admin, category: 'servers'),
isTrue,
);
expect(
state.hasPermission(Domain.controlRoom, Action.admin, category: 'general'),
isFalse,
);
});
test('isGlobalAdmin returns true for admin.general:admin role', () {
final state = AuthState(
isAuthenticated: true,
roles: const [
Role(
id: '1',
name: 'admin.general:admin',
domain: Domain.admin,
category: 'general',
action: Action.admin,
),
],
);
expect(state.isGlobalAdmin, isTrue);
});
test('isGlobalAdmin returns false for non-admin roles', () {
final state = AuthState(
isAuthenticated: true,
roles: const [
Role(
id: '1',
name: 'control-room.general:admin',
domain: Domain.controlRoom,
category: 'general',
action: Action.admin,
),
],
);
expect(state.isGlobalAdmin, isFalse);
});
test('global admin has access to all domains', () {
final state = AuthState(
isAuthenticated: true,
roles: const [
Role(
id: '1',
name: 'admin.general:admin',
domain: Domain.admin,
category: 'general',
action: Action.admin,
),
],
);
// Global admin should have access to everything
expect(state.hasPermission(Domain.controlRoom, Action.admin), isTrue);
expect(state.hasPermission(Domain.media, Action.editor), isTrue);
expect(state.hasPermission(Domain.library, Action.viewer), isTrue);
expect(
state.hasPermission(Domain.documents, Action.user, category: 'custom'),
isTrue,
);
});
test('empty roles list denies all permissions', () {
const state = AuthState(isAuthenticated: true, roles: []);
expect(state.hasPermission(Domain.controlRoom, Action.viewer), isFalse);
expect(state.isGlobalAdmin, isFalse);
});
test('multiple roles are evaluated correctly', () {
final state = AuthState(
isAuthenticated: true,
roles: const [
Role(
id: '1',
name: 'media.general:viewer',
domain: Domain.media,
category: 'general',
action: Action.viewer,
),
Role(
id: '2',
name: 'control-room.general:admin',
domain: Domain.controlRoom,
category: 'general',
action: Action.admin,
),
],
);
// Should have permissions from both roles
expect(state.hasPermission(Domain.media, Action.viewer), isTrue);
expect(state.hasPermission(Domain.controlRoom, Action.admin), isTrue);
// But not permissions not granted by any role
expect(state.hasPermission(Domain.media, Action.admin), isFalse);
expect(state.hasPermission(Domain.library, Action.viewer), isFalse);
});
});
}
+283
View File
@@ -0,0 +1,283 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/core/auth/permissions.dart';
void main() {
group('Domain', () {
group('fromString', () {
test('parses valid domain strings', () {
expect(Domain.fromString('control-room'), equals(Domain.controlRoom));
expect(Domain.fromString('library'), equals(Domain.library));
expect(Domain.fromString('media'), equals(Domain.media));
expect(Domain.fromString('ai'), equals(Domain.ai));
expect(Domain.fromString('housekeeper'), equals(Domain.housekeeper));
expect(Domain.fromString('developer'), equals(Domain.developer));
expect(Domain.fromString('documents'), equals(Domain.documents));
expect(Domain.fromString('gaming'), equals(Domain.gaming));
expect(Domain.fromString('admin'), equals(Domain.admin));
});
test('returns null for invalid domain', () {
expect(Domain.fromString('invalid'), isNull);
expect(Domain.fromString(''), isNull);
expect(Domain.fromString('CONTROL-ROOM'), isNull);
});
});
test('value returns correct string', () {
expect(Domain.controlRoom.value, equals('control-room'));
expect(Domain.library.value, equals('library'));
expect(Domain.admin.value, equals('admin'));
});
});
group('Action', () {
group('fromString', () {
test('parses valid action strings', () {
expect(Action.fromString('viewer'), equals(Action.viewer));
expect(Action.fromString('user'), equals(Action.user));
expect(Action.fromString('editor'), equals(Action.editor));
expect(Action.fromString('admin'), equals(Action.admin));
});
test('returns null for invalid action', () {
expect(Action.fromString('invalid'), isNull);
expect(Action.fromString(''), isNull);
expect(Action.fromString('ADMIN'), isNull);
});
});
group('level', () {
test('has correct hierarchy levels', () {
expect(Action.viewer.level, equals(1));
expect(Action.user.level, equals(2));
expect(Action.editor.level, equals(3));
expect(Action.admin.level, equals(4));
});
test('levels are ordered correctly', () {
expect(Action.viewer.level, lessThan(Action.user.level));
expect(Action.user.level, lessThan(Action.editor.level));
expect(Action.editor.level, lessThan(Action.admin.level));
});
});
group('grants', () {
test('admin grants all actions', () {
expect(Action.admin.grants(Action.admin), isTrue);
expect(Action.admin.grants(Action.editor), isTrue);
expect(Action.admin.grants(Action.user), isTrue);
expect(Action.admin.grants(Action.viewer), isTrue);
});
test('editor grants editor and below', () {
expect(Action.editor.grants(Action.admin), isFalse);
expect(Action.editor.grants(Action.editor), isTrue);
expect(Action.editor.grants(Action.user), isTrue);
expect(Action.editor.grants(Action.viewer), isTrue);
});
test('user grants user and below', () {
expect(Action.user.grants(Action.admin), isFalse);
expect(Action.user.grants(Action.editor), isFalse);
expect(Action.user.grants(Action.user), isTrue);
expect(Action.user.grants(Action.viewer), isTrue);
});
test('viewer only grants viewer', () {
expect(Action.viewer.grants(Action.admin), isFalse);
expect(Action.viewer.grants(Action.editor), isFalse);
expect(Action.viewer.grants(Action.user), isFalse);
expect(Action.viewer.grants(Action.viewer), isTrue);
});
});
});
group('Role', () {
group('grants', () {
test('grants permission for matching domain and category', () {
const role = Role(
id: '1',
name: 'control-room.general:admin',
domain: Domain.controlRoom,
category: 'general',
action: Action.admin,
);
expect(role.grants(Domain.controlRoom, Action.admin), isTrue);
expect(role.grants(Domain.controlRoom, Action.editor), isTrue);
expect(role.grants(Domain.controlRoom, Action.user), isTrue);
expect(role.grants(Domain.controlRoom, Action.viewer), isTrue);
});
test('denies permission for different domain', () {
const role = Role(
id: '1',
name: 'control-room.general:admin',
domain: Domain.controlRoom,
category: 'general',
action: Action.admin,
);
expect(role.grants(Domain.media, Action.viewer), isFalse);
expect(role.grants(Domain.library, Action.viewer), isFalse);
});
test('denies permission for different category', () {
const role = Role(
id: '1',
name: 'control-room.servers:admin',
domain: Domain.controlRoom,
category: 'servers',
action: Action.admin,
);
expect(
role.grants(Domain.controlRoom, Action.admin, category: 'general'),
isFalse,
);
expect(
role.grants(Domain.controlRoom, Action.admin, category: 'servers'),
isTrue,
);
});
test('global admin grants all permissions', () {
const globalAdmin = Role(
id: '1',
name: 'admin.general:admin',
domain: Domain.admin,
category: 'general',
action: Action.admin,
);
// Should grant any domain, category, action
expect(globalAdmin.grants(Domain.controlRoom, Action.admin), isTrue);
expect(globalAdmin.grants(Domain.media, Action.editor), isTrue);
expect(globalAdmin.grants(Domain.library, Action.viewer), isTrue);
expect(
globalAdmin.grants(Domain.documents, Action.user, category: 'specific'),
isTrue,
);
});
test('non-global admin role does not grant everything', () {
const domainAdmin = Role(
id: '1',
name: 'admin.specific:admin',
domain: Domain.admin,
category: 'specific', // Not 'general'
action: Action.admin,
);
// Should not grant arbitrary permissions
expect(domainAdmin.grants(Domain.controlRoom, Action.viewer), isFalse);
});
});
});
group('RoleListPermissions extension', () {
final roles = [
const Role(
id: '1',
name: 'control-room.general:admin',
domain: Domain.controlRoom,
category: 'general',
action: Action.admin,
),
const Role(
id: '2',
name: 'media.general:viewer',
domain: Domain.media,
category: 'general',
action: Action.viewer,
),
];
group('hasPermission', () {
test('returns true when any role grants permission', () {
expect(
roles.hasPermission(Domain.controlRoom, Action.admin),
isTrue,
);
expect(
roles.hasPermission(Domain.controlRoom, Action.viewer),
isTrue,
);
expect(
roles.hasPermission(Domain.media, Action.viewer),
isTrue,
);
});
test('returns false when no role grants permission', () {
expect(
roles.hasPermission(Domain.media, Action.editor),
isFalse,
);
expect(
roles.hasPermission(Domain.library, Action.viewer),
isFalse,
);
});
test('returns false for empty role list', () {
expect(
<Role>[].hasPermission(Domain.controlRoom, Action.viewer),
isFalse,
);
});
});
group('isGlobalAdmin', () {
test('returns true when global admin role present', () {
final adminRoles = [
const Role(
id: '1',
name: 'admin.general:admin',
domain: Domain.admin,
category: 'general',
action: Action.admin,
),
];
expect(adminRoles.isGlobalAdmin, isTrue);
});
test('returns false when no global admin role', () {
expect(roles.isGlobalAdmin, isFalse);
});
test('returns false for empty role list', () {
expect(<Role>[].isGlobalAdmin, isFalse);
});
test('returns false for admin domain with non-general category', () {
final limitedAdmin = [
const Role(
id: '1',
name: 'admin.specific:admin',
domain: Domain.admin,
category: 'specific',
action: Action.admin,
),
];
expect(limitedAdmin.isGlobalAdmin, isFalse);
});
test('returns false for admin domain with non-admin action', () {
final viewerAdmin = [
const Role(
id: '1',
name: 'admin.general:viewer',
domain: Domain.admin,
category: 'general',
action: Action.viewer,
),
];
expect(viewerAdmin.isGlobalAdmin, isFalse);
});
});
});
}
+147
View File
@@ -0,0 +1,147 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/core/auth/user_preferences.dart';
void main() {
group('UserPreferences', () {
group('default constructor', () {
test('creates with default values', () {
const prefs = UserPreferences();
expect(prefs.theme, equals('system'));
expect(prefs.defaultRoom, equals('front-hall'));
expect(prefs.preferencesJson, isEmpty);
});
});
group('custom constructor', () {
test('creates with custom values', () {
const prefs = UserPreferences(
theme: 'dark',
defaultRoom: 'kitchen',
preferencesJson: {'sidebar_collapsed': true, 'font_size': 14},
);
expect(prefs.theme, equals('dark'));
expect(prefs.defaultRoom, equals('kitchen'));
expect(prefs.preferencesJson['sidebar_collapsed'], isTrue);
expect(prefs.preferencesJson['font_size'], equals(14));
});
});
group('fromJson', () {
test('deserializes from JSON', () {
final json = {
'theme': 'light',
'defaultRoom': 'living-room',
'preferencesJson': {'key': 'value'},
};
final prefs = UserPreferences.fromJson(json);
expect(prefs.theme, equals('light'));
expect(prefs.defaultRoom, equals('living-room'));
expect(prefs.preferencesJson['key'], equals('value'));
});
test('uses defaults for missing fields', () {
final json = <String, dynamic>{};
final prefs = UserPreferences.fromJson(json);
expect(prefs.theme, equals('system'));
expect(prefs.defaultRoom, equals('front-hall'));
expect(prefs.preferencesJson, isEmpty);
});
test('handles partial JSON', () {
final json = {'theme': 'dark'};
final prefs = UserPreferences.fromJson(json);
expect(prefs.theme, equals('dark'));
expect(prefs.defaultRoom, equals('front-hall'));
});
});
group('toJson', () {
test('serializes to JSON', () {
const prefs = UserPreferences(
theme: 'dark',
defaultRoom: 'office',
preferencesJson: {'notifications': true},
);
final json = prefs.toJson();
expect(json['theme'], equals('dark'));
expect(json['defaultRoom'], equals('office'));
expect(json['preferencesJson']['notifications'], isTrue);
});
test('round-trip serialization preserves data', () {
const original = UserPreferences(
theme: 'light',
defaultRoom: 'bedroom',
preferencesJson: {'compact_mode': false},
);
final json = original.toJson();
final restored = UserPreferences.fromJson(json);
expect(restored.theme, equals(original.theme));
expect(restored.defaultRoom, equals(original.defaultRoom));
expect(
restored.preferencesJson['compact_mode'],
equals(original.preferencesJson['compact_mode']),
);
});
});
group('copyWith', () {
test('creates a copy with modified values', () {
const original = UserPreferences(
theme: 'system',
defaultRoom: 'front-hall',
);
final updated = original.copyWith(theme: 'dark');
// Original unchanged
expect(original.theme, equals('system'));
// Updated has new value
expect(updated.theme, equals('dark'));
// Preserved unchanged values
expect(updated.defaultRoom, equals(original.defaultRoom));
});
});
group('equality', () {
test('two identical preferences are equal', () {
const prefs1 = UserPreferences(theme: 'dark', defaultRoom: 'office');
const prefs2 = UserPreferences(theme: 'dark', defaultRoom: 'office');
expect(prefs1, equals(prefs2));
});
test('different preferences are not equal', () {
const prefs1 = UserPreferences(theme: 'dark');
const prefs2 = UserPreferences(theme: 'light');
expect(prefs1, isNot(equals(prefs2)));
});
});
group('theme validation', () {
test('accepts valid theme values', () {
const themes = ['system', 'light', 'dark'];
for (final theme in themes) {
final prefs = UserPreferences(theme: theme);
expect(prefs.theme, equals(theme));
}
});
});
});
}
+51
View File
@@ -0,0 +1,51 @@
#!/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.

After

Width:  |  Height:  |  Size: 4.2 KiB

-7
View File
@@ -1,7 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>OK</title>
</head>
<body>OK</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"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
@@ -0,0 +1,30 @@
<!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,10 +27,19 @@
<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>