Compare commits

...
16 Commits
Author SHA1 Message Date
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
21 changed files with 1060 additions and 278 deletions
+2 -4
View File
@@ -59,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:**
+82
View File
@@ -7,6 +7,88 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
+35
View File
@@ -420,6 +420,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 |
+3 -2
View File
@@ -2,6 +2,7 @@ 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;
@@ -9,7 +10,7 @@ import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
part 'api_client.g.dart';
/// Provides the Dio instance for Core API.
@riverpod
@persistentRiverpod
Dio coreApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.coreApiUrl,
@@ -33,7 +34,7 @@ Dio coreApiClient(Ref ref) {
}
/// Provides the Dio instance for Tatlock API.
@riverpod
@persistentRiverpod
Dio tatlockApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
+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);
}
}
+5 -36
View File
@@ -6,6 +6,7 @@ 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';
@@ -24,7 +25,7 @@ part 'auth_provider.g.dart';
///
/// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences.
@riverpod
@persistentRiverpod
class AuthNotifier extends _$AuthNotifier {
// Storage keys
static const _accessTokenKey = 'auth_access_token';
@@ -40,44 +41,12 @@ class AuthNotifier extends _$AuthNotifier {
@override
Future<AuthState> build() async {
// On web with auth required, use silent OIDC to get JWT
if (kIsWeb && AppConfig.requiresAuth) {
// First check if we have stored tokens
final storedAuth = await _loadStoredAuth();
if (storedAuth.isAuthenticated && !storedAuth.isTokenExpired) {
developer.log('Web: Using stored tokens for ${storedAuth.userName}', name: 'auth');
return storedAuth;
}
// No valid tokens - initiate silent OIDC
// NPM forward auth ensures user has Authentik session
// prompt=none will get us a token instantly without UI
developer.log('Web: No valid tokens, initiating silent OIDC', name: 'auth');
_initiateSilentOidc();
// Return unauthenticated state - will redirect before this matters
return const AuthState();
}
// Mobile/LAN: Load stored auth from SharedPreferences
// 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();
}
/// Initiate silent OIDC flow on web.
///
/// Uses prompt=none to get a token without showing login UI.
/// Relies on existing Authentik session (established via NPM forward auth).
Future<void> _initiateSilentOidc() async {
try {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl(silent: true);
developer.log('Redirecting to silent OIDC: $authUrl', name: 'auth');
web_utils.redirectTo(authUrl);
} catch (e) {
developer.log('Failed to initiate silent OIDC: $e', name: 'auth');
}
}
Future<AuthState> _loadStoredAuth() async {
try {
final prefs = await SharedPreferences.getInstance();
+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);
+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;
@@ -69,44 +69,11 @@ class _SectionContent extends ConsumerWidget {
@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 => const _ContainersSection(),
ControlRoomNav.proxyHosts => const ProxyHostsPage(),
_ => _PlaceholderSection(nav: nav),
};
}
}
@@ -150,6 +117,36 @@ class _PlaceholderSection extends StatelessWidget {
}
}
/// 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),
),
],
);
}
}
/// Stacks filter panel for Containers section (includes "All Containers" option).
class _StacksFilterPanel extends ConsumerWidget {
const _StacksFilterPanel();
+17 -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,8 @@ 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)),
),
];
}
+3 -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,8 @@ 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)),
),
];
}
@@ -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),
],
),
),
],
),
);
}
}
+12 -5
View File
@@ -4,19 +4,26 @@ import 'package:flutter/material.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();
// Use path-based URLs on web (no-op on mobile/desktop)
configureUrlStrategy();
developer.log(
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
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()));
}
+15 -164
View File
@@ -1,21 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/features/settings/presentation/pages/settings_page.dart';
import 'package:tatlock_ui/shared/layouts/app_scaffold.dart';
part 'app_router.g.dart';
/// No-animation page builder for instant transitions
Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget child) {
return NoTransitionPage<void>(
key: state.pageKey,
child: child,
);
}
/// Route paths as constants.
abstract class AppRoutes {
static const frontHall = '/';
static const parlor = '/parlor';
static const settings = '/settings';
static const callback = '/callback';
}
/// Provides the GoRouter instance.
@@ -25,17 +31,6 @@ GoRouter appRouter(Ref ref) {
initialLocation: AppRoutes.frontHall,
debugLogDiagnostics: true,
routes: [
// OIDC callback route (handles auth code exchange)
GoRoute(
path: AppRoutes.callback,
name: 'callback',
builder: (context, state) => _OidcCallbackPage(
code: state.uri.queryParameters['code'],
callbackState: state.uri.queryParameters['state'],
error: state.uri.queryParameters['error'],
errorDescription: state.uri.queryParameters['error_description'],
),
),
// Main app routes (inside shell with app scaffold)
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
@@ -43,21 +38,22 @@ GoRouter appRouter(Ref ref) {
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()),
),
],
),
@@ -100,148 +96,3 @@ class _PlaceholderPage extends StatelessWidget {
}
}
/// OIDC callback page that handles the authorization code exchange.
class _OidcCallbackPage extends ConsumerStatefulWidget {
const _OidcCallbackPage({
this.code,
this.callbackState,
this.error,
this.errorDescription,
});
final String? code;
final String? callbackState;
final String? error;
final String? errorDescription;
@override
ConsumerState<_OidcCallbackPage> createState() => _OidcCallbackPageState();
}
class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
bool _isProcessing = true;
String? _error;
@override
void initState() {
super.initState();
// Defer callback processing to avoid Riverpod state modification during build
WidgetsBinding.instance.addPostFrameCallback((_) {
_processCallback();
});
}
Future<void> _processCallback() async {
// Check mounted before any async work
if (!mounted) return;
// Check for error from Authentik
if (widget.error != null) {
// Silent OIDC (prompt=none) failed - no existing session
// Fall back to regular OIDC flow to show login UI
if (widget.error == 'login_required') {
ref.read(authProvider.notifier).signIn();
return;
}
setState(() {
_isProcessing = false;
_error = widget.errorDescription ?? widget.error;
});
return;
}
// Check for required parameters
if (widget.code == null || widget.callbackState == null) {
setState(() {
_isProcessing = false;
_error = 'Invalid callback - missing code or state parameter';
});
return;
}
// Get notifier reference before async gap to avoid disposed ref errors
final authNotifier = ref.read(authProvider.notifier);
// Exchange code for tokens
try {
await authNotifier.handleOidcCallback(
widget.code!,
widget.callbackState!,
);
// Navigate to home on success
if (mounted) {
context.go(AppRoutes.frontHall);
}
} catch (e) {
if (mounted) {
setState(() {
_isProcessing = false;
_error = e.toString();
});
}
}
}
@override
Widget build(BuildContext context) {
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(
_error != null ? Icons.error_outline : Icons.home_work_outlined,
size: 64,
color: _error != null ? colorScheme.error : colorScheme.primary,
),
const SizedBox(height: 24),
Text(
_error != null ? 'Authentication Failed' : 'Signing in...',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
if (_isProcessing)
const CircularProgressIndicator()
else if (_error != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_error!,
style: TextStyle(color: colorScheme.onErrorContainer),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => context.go(AppRoutes.frontHall),
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
],
),
),
),
),
),
);
}
}
@@ -1,7 +1,10 @@
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/theme/theme_provider.dart';
import 'package:tatlock_ui/routing/app_router.dart';
/// Profile dropdown menu in the header.
@@ -13,6 +16,7 @@ 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(
@@ -76,6 +80,102 @@ class ProfileDropdown extends ConsumerWidget {
),
),
// 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: 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: 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: 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)
const PopupMenuItem<String>(
@@ -93,6 +193,12 @@ class ProfileDropdown extends ConsumerWidget {
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();
}
@@ -126,4 +232,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');
}
}
}
+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;
}
+1 -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: 1.1.4+1
version: 1.1.15+1
environment:
sdk: ^3.10.4
+6 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env dart
// Generates web/health.json from pubspec.yaml
// Generates web/health/health.json from pubspec.yaml
// Run: dart run tool/generate_health_json.dart
// ignore_for_file: avoid_print
@@ -37,15 +37,15 @@ void main() {
'fullVersion': '$version+$buildNumber',
};
final webDir = Directory('web');
if (!webDir.existsSync()) {
webDir.createSync(recursive: true);
final healthDir = Directory('web/health');
if (!healthDir.existsSync()) {
healthDir.createSync(recursive: true);
}
final healthFile = File('web/health.json');
final healthFile = File('web/health/health.json');
healthFile.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(health),
);
print('Generated web/health.json with version $version+$buildNumber');
print('Generated web/health/health.json with version $version+$buildNumber');
}
+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"
}
+1 -1
View File
@@ -13,7 +13,7 @@
<h1 id="status">Loading...</h1>
<pre id="data"></pre>
<script>
fetch('/health.json')
fetch('./health.json')
.then(r => r.json())
.then(data => {
document.getElementById('status').textContent = data.status?.toUpperCase() || 'OK';
+8
View File
@@ -32,6 +32,14 @@
<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>