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>
This commit is contained in:
Jeroen Schweitzer
2026-01-04 13:52:13 +01:00
co-authored by Claude Opus 4.5
parent b35f495537
commit 16bad327f1
4 changed files with 173 additions and 167 deletions
+11
View File
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [1.0.11] - 2026-01-04
### Changed ### Changed
+3 -164
View File
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/auth/auth_provider.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/control_room/router.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.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/security/router.dart';
@@ -16,51 +15,16 @@ abstract class AppRoutes {
static const frontHall = '/'; static const frontHall = '/';
static const parlor = '/parlor'; static const parlor = '/parlor';
static const settings = '/settings'; static const settings = '/settings';
static const login = '/login';
static const callback = '/callback'; static const callback = '/callback';
} }
/// Provides the GoRouter instance. /// Provides the GoRouter instance.
@riverpod @riverpod
GoRouter appRouter(Ref ref) { GoRouter appRouter(Ref ref) {
final authState = ref.watch(authProvider);
return GoRouter( return GoRouter(
initialLocation: AppRoutes.frontHall, initialLocation: AppRoutes.frontHall,
debugLogDiagnostics: true, 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: [ 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) // OIDC callback route (handles auth code exchange)
GoRoute( GoRoute(
path: AppRoutes.callback, 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. /// OIDC callback page that handles the authorization code exchange.
class _OidcCallbackPage extends ConsumerStatefulWidget { class _OidcCallbackPage extends ConsumerStatefulWidget {
const _OidcCallbackPage({ const _OidcCallbackPage({
@@ -376,9 +215,9 @@ class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => context.go(AppRoutes.login), onPressed: () => context.go(AppRoutes.frontHall),
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.refresh),
label: const Text('Back to Login'), label: const Text('Try again'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48), minimumSize: const Size(double.infinity, 48),
), ),
+158 -2
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/control_room/router.dart';
import 'package:tatlock_ui/features/security/router.dart'; import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/routing/app_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. /// 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: /// 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) │ /// │ BODY: Room page content (may include room-specific sidebar) │
/// └─────────────────────────────────────────────────────────────────┘ /// └─────────────────────────────────────────────────────────────────┘
/// ``` /// ```
class AppScaffold extends StatelessWidget { class AppScaffold extends ConsumerStatefulWidget {
const AppScaffold({super.key, required this.child}); const AppScaffold({super.key, required this.child});
final Widget child; final Widget child;
@override
ConsumerState<AppScaffold> createState() => _AppScaffoldState();
}
class _AppScaffoldState extends ConsumerState<AppScaffold> {
// Header height must match TopHeaderBar._headerHeight // Header height must match TopHeaderBar._headerHeight
static const double _headerHeight = 56.0; static const double _headerHeight = 56.0;
bool _authInitiated = false;
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
@@ -32,7 +79,7 @@ class AppScaffold extends StatelessWidget {
Positioned.fill( Positioned.fill(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: _headerHeight), 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) { int _selectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
+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 # 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 # 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. # 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: environment:
sdk: ^3.10.4 sdk: ^3.10.4