Compare commits

...
9 Commits
Author SHA1 Message Date
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
17 changed files with 364 additions and 238 deletions
+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/
+54
View File
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
+50 -24
View File
@@ -1,4 +1,4 @@
import 'dart:convert';
import 'dart:convert' show base64Url, jsonDecode, jsonEncode, utf8;
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart' show kIsWeb;
@@ -265,23 +265,27 @@ class AuthNotifier extends _$AuthNotifier {
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);
// Step 2: Decode JWT to extract user info (skip core-api sync)
final claims = _decodeJwtClaims(tokens.accessToken);
final userName = claims['name'] as String? ??
claims['preferred_username'] as String? ??
'User';
final userEmail = claims['email'] as String? ?? '';
final authentikId = claims['sub'] as String?;
final groups = (claims['groups'] as List<dynamic>?)?.cast<String>() ?? [];
// Step 3: Store credentials and user data
developer.log('JWT claims: name=$userName, email=$userEmail, groups=$groups', name: 'auth');
// Step 3: Store credentials and user data from JWT
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,
authentikId: authentikId,
userName: userName,
userEmail: userEmail,
// Roles from groups - for now just store group names
// Full role parsing can be done later if needed
);
state = AsyncData(AuthState(
@@ -289,19 +293,12 @@ class AuthNotifier extends _$AuthNotifier {
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,
authentikId: authentikId,
userName: userName,
userEmail: userEmail,
));
developer.log(
'Authenticated as ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
developer.log('Authenticated as $userName', name: 'auth');
// Clean up the URL by removing the query parameters
web_utils.replaceUrl('/');
@@ -314,6 +311,35 @@ class AuthNotifier extends _$AuthNotifier {
}
}
/// Decode JWT payload without verification (validation happens server-side).
Map<String, dynamic> _decodeJwtClaims(String jwt) {
try {
final parts = jwt.split('.');
if (parts.length != 3) {
developer.log('Invalid JWT format', name: 'auth');
return {};
}
// Decode the payload (second part)
String payload = parts[1];
// Add padding if needed for base64
switch (payload.length % 4) {
case 2:
payload += '==';
break;
case 3:
payload += '=';
break;
}
final decoded = utf8.decode(base64Url.decode(payload));
return jsonDecode(decoded) as Map<String, dynamic>;
} catch (e) {
developer.log('Failed to decode JWT: $e', name: 'auth');
return {};
}
}
/// Sign out and clear stored credentials.
Future<void> signOut() async {
await _clearStoredAuth();
+32 -16
View File
@@ -8,6 +8,7 @@ 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.
///
@@ -25,9 +26,9 @@ class OidcServiceWeb implements OidcService {
/// Redirect URI for web.
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
// PKCE state stored during auth flow (in-memory for single-page app)
static String? _codeVerifier;
static String? _state;
// 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.
///
@@ -39,11 +40,15 @@ class OidcServiceWeb implements OidcService {
final authEndpoint = discovery['authorization_endpoint'] as String;
// Generate PKCE code verifier and challenge
_codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(_codeVerifier!);
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
// Generate state for CSRF protection
_state = _generateRandomString(32);
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 = {
@@ -53,7 +58,7 @@ class OidcServiceWeb implements OidcService {
'scope': _scopes.join(' '),
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': _state,
'state': state,
};
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
@@ -67,12 +72,21 @@ class OidcServiceWeb implements OidcService {
/// [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 (_state == null || state != _state) {
if (storedState == null || state != storedState) {
_clearPkceState();
throw OidcException('State mismatch - possible CSRF attack');
}
if (_codeVerifier == null) {
if (codeVerifier == null) {
_clearPkceState();
throw OidcException('No code verifier - flow not started properly');
}
@@ -91,7 +105,7 @@ class OidcServiceWeb implements OidcService {
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'code': code,
'code_verifier': _codeVerifier,
'code_verifier': codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
@@ -102,8 +116,7 @@ class OidcServiceWeb implements OidcService {
developer.log('Token exchange successful', name: 'oidc_web');
// Clear stored PKCE state
_codeVerifier = null;
_state = null;
_clearPkceState();
return OidcTokens(
accessToken: data['access_token'] as String,
@@ -115,14 +128,17 @@ class OidcServiceWeb implements OidcService {
);
} on DioException catch (e) {
developer.log('Token exchange failed: $e', name: 'oidc_web');
_clearPkceState();
throw OidcException('Token exchange failed: ${e.message}');
} finally {
// Clear PKCE state on error too
_codeVerifier = null;
_state = null;
}
}
/// 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 {
+15
View File
@@ -15,3 +15,18 @@ String getCurrentUrl() {
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');
}
+15
View File
@@ -17,3 +17,18 @@ String getCurrentUrl() {
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);
}
+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();
}
+5 -5
View File
@@ -4,19 +4,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
import 'core/config/url_strategy.dart';
import 'version.g.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Use path-based URLs on web (no-op on mobile/desktop)
configureUrlStrategy();
developer.log(
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
runApp(
const ProviderScope(
child: TatlockApp(),
),
);
runApp(const ProviderScope(child: TatlockApp()));
}
+7 -165
View File
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import 'package:tatlock_ui/features/security/router.dart';
@@ -16,51 +15,16 @@ abstract class AppRoutes {
static const frontHall = '/';
static const parlor = '/parlor';
static const settings = '/settings';
static const login = '/login';
static const callback = '/callback';
}
/// Provides the GoRouter instance.
@riverpod
GoRouter appRouter(Ref ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: AppRoutes.frontHall,
debugLogDiagnostics: true,
redirect: (context, state) {
// No auth required in LAN mode
if (!AppConfig.requiresAuth) {
return null;
}
final isAuthenticated = authState.value?.isAuthenticated ?? false;
final isLoginRoute = state.matchedLocation == AppRoutes.login;
// If not authenticated, redirect to login (except if already on login)
if (!isAuthenticated && !isLoginRoute) {
return AppRoutes.login;
}
// If authenticated and on login page, redirect to home
if (isAuthenticated && isLoginRoute) {
return AppRoutes.frontHall;
}
// Allow callback route through without auth check
if (state.matchedLocation == AppRoutes.callback) {
return null;
}
return null;
},
routes: [
// Login route (outside shell - no app scaffold)
GoRoute(
path: AppRoutes.login,
name: 'login',
builder: (context, state) => const _LoginPage(),
),
// OIDC callback route (handles auth code exchange)
GoRoute(
path: AppRoutes.callback,
@@ -136,131 +100,6 @@ class _PlaceholderPage extends StatelessWidget {
}
}
/// Login page displayed when user is not authenticated.
class _LoginPage extends ConsumerWidget {
const _LoginPage();
@override
Widget build(BuildContext context, WidgetRef ref) {
final authAsync = ref.watch(authProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.home_work_outlined,
size: 64,
color: colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Tatlock Estate',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Sign in to access the estate management system',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
authAsync.when(
data: (_) => _buildSignInContent(context, ref),
loading: () => const Column(
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(height: 16),
Text('Checking authentication...'),
],
),
error: (error, _) => _buildErrorContent(context, ref, error),
),
],
),
),
),
),
),
);
}
Widget _buildSignInContent(BuildContext context, WidgetRef ref) {
// Same sign in button for both web and mobile
return FilledButton.icon(
onPressed: () => ref.read(authProvider.notifier).signIn(),
icon: const Icon(Icons.login),
label: const Text('Sign in with Authentik'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
);
}
Widget _buildErrorContent(BuildContext context, WidgetRef ref, Object error) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
_formatError(error),
style: TextStyle(color: colorScheme.onErrorContainer),
),
),
],
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => ref.read(authProvider.notifier).signIn(),
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
);
}
String _formatError(Object error) {
final message = error.toString();
if (message.contains('user_cancelled')) {
return 'Sign in was cancelled';
}
if (message.contains('network')) {
return 'Network error. Please check your connection.';
}
return 'Authentication failed. Please try again.';
}
}
/// OIDC callback page that handles the authorization code exchange.
class _OidcCallbackPage extends ConsumerStatefulWidget {
const _OidcCallbackPage({
@@ -286,7 +125,10 @@ class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
@override
void initState() {
super.initState();
_processCallback();
// Defer callback processing to avoid Riverpod state modification during build
WidgetsBinding.instance.addPostFrameCallback((_) {
_processCallback();
});
}
Future<void> _processCallback() async {
@@ -373,9 +215,9 @@ class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => context.go(AppRoutes.login),
icon: const Icon(Icons.arrow_back),
label: const Text('Back to Login'),
onPressed: () => context.go(AppRoutes.frontHall),
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
+158 -2
View File
@@ -1,5 +1,8 @@
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 +10,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 +23,55 @@ 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);
}
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);
}
// 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, 'Checking authentication...'),
error: (error, _) => _buildAuthErrorScreen(context, error),
);
}
Widget _buildScaffold(BuildContext context) {
return Scaffold(
body: Stack(
children: [
@@ -32,7 +79,7 @@ class AppScaffold extends StatelessWidget {
Positioned.fill(
child: Padding(
padding: const EdgeInsets.only(top: _headerHeight),
child: child,
child: widget.child,
),
),
@@ -51,6 +98,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;
-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 = '1.0.4';
static const int buildNumber = 1;
static const String fullVersion = '1.0.4+1';
}
+3 -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.0.5+1
version: 1.0.12+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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

-8
View File
@@ -1,8 +0,0 @@
{
"status": "healthy",
"name": "tatlock_ui",
"title": "Tatlock - a Home Lab AI",
"version": "1.0.4",
"buildNumber": 1,
"fullVersion": "1.0.4+1"
}
+1
View File
@@ -27,6 +27,7 @@
<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>