From 16bad327f1143cda755a403dc46a6bbf48767f47 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 4 Jan 2026 13:52:13 +0100 Subject: [PATCH] feat(auth): auto-initiate OIDC, remove login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 11 ++ lib/routing/app_router.dart | 167 +-------------------------- lib/shared/layouts/app_scaffold.dart | 160 ++++++++++++++++++++++++- pubspec.yaml | 2 +- 4 files changed, 173 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b21e8..7f02e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ 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 diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index bf22835..affd72f 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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; - } - - // Allow callback route through without auth check (must be checked FIRST!) - if (state.matchedLocation == AppRoutes.callback) { - return null; - } - - final isAuthenticated = authState.value?.isAuthenticated ?? false; - final isLoginRoute = state.matchedLocation == AppRoutes.login; - - // If not authenticated, redirect to login (except if already on login) - if (!isAuthenticated && !isLoginRoute) { - return AppRoutes.login; - } - - // If authenticated and on login page, redirect to home - if (isAuthenticated && isLoginRoute) { - return AppRoutes.frontHall; - } - - return null; - }, routes: [ - // Login route (outside shell - no app scaffold) - GoRoute( - path: AppRoutes.login, - name: 'login', - builder: (context, state) => const _LoginPage(), - ), // 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({ @@ -376,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), ), diff --git a/lib/shared/layouts/app_scaffold.dart b/lib/shared/layouts/app_scaffold.dart index df1f34b..ae4bd91 100644 --- a/lib/shared/layouts/app_scaffold.dart +++ b/lib/shared/layouts/app_scaffold.dart @@ -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 createState() => _AppScaffoldState(); +} + +class _AppScaffoldState extends ConsumerState { // 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; diff --git a/pubspec.yaml b/pubspec.yaml index db8754c..878561e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.11+1 +version: 1.0.12+1 environment: sdk: ^3.10.4