diff --git a/back001/README.md b/back001/README.md index b5d45e9..5fe4829 100644 --- a/back001/README.md +++ b/back001/README.md @@ -13,7 +13,7 @@ Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-care docker compose up -d ``` -2) Run the API: +2) Run the API (JWT resource server): ```bash ./gradlew :app:bootRun -Dspring.profiles.active=local @@ -25,14 +25,20 @@ docker compose up -d ./gradlew :workers:notification-worker:bootRun ``` -## Auth (local profile) -For local development, add headers: -- `X-Local-User`: user id -- `X-Local-Tenant`: tenant id +## Auth +- The backend is a JWT resource server and does not handle user passwords. +- Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local` + and `ALLOW_LOCAL_AUTH=true`. + +Local headers (dev only): +- `X-Local-Email`: user id/email - `X-Local-Roles`: comma-separated roles (ADMIN, DOCTOR, CAREGIVER) +- `X-Tenant-Id`: tenant id ## OpenAPI - http://localhost:8080/swagger-ui/index.html +## Health +- http://localhost:8080/health ## Key services - Postgres: localhost:5432 (mosenioring/mosenioring) diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/api/HealthController.kt b/back001/app/src/main/kotlin/com/mosenioring/app/api/HealthController.kt new file mode 100644 index 0000000..c7c0f10 --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/api/HealthController.kt @@ -0,0 +1,14 @@ +package com.mosenioring.app.api + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class HealthController { + + @GetMapping("/health") + fun health(): ResponseEntity> { + return ResponseEntity.ok(mapOf("status" to "ok")) + } +} diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt b/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt index b94c103..0dca6e4 100644 --- a/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt +++ b/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt @@ -23,7 +23,7 @@ class SecurityConfig { http .csrf { it.disable() } .authorizeHttpRequests { - it.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN") it.anyRequest().authenticated() } diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/security/LocalAuthFilter.kt b/back001/common/src/main/kotlin/com/mosenioring/common/security/LocalAuthFilter.kt index ecd04e2..85f260b 100644 --- a/back001/common/src/main/kotlin/com/mosenioring/common/security/LocalAuthFilter.kt +++ b/back001/common/src/main/kotlin/com/mosenioring/common/security/LocalAuthFilter.kt @@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.context.annotation.Profile +import org.springframework.beans.factory.annotation.Value import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.context.SecurityContextHolder @@ -12,15 +13,21 @@ import org.springframework.web.filter.OncePerRequestFilter @Component @Profile("local") -class LocalAuthFilter : OncePerRequestFilter() { +class LocalAuthFilter( + @Value("\${ALLOW_LOCAL_AUTH:false}") private val allowLocalAuth: Boolean +) : OncePerRequestFilter() { override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain ) { - val userId = request.getHeader("X-Local-User") - val tenantId = request.getHeader("X-Local-Tenant") + if (!allowLocalAuth) { + filterChain.doFilter(request, response) + return + } + val userId = request.getHeader("X-Local-User") ?: request.getHeader("X-Local-Email") + val tenantId = request.getHeader("X-Local-Tenant") ?: request.getHeader("X-Tenant-Id") val rolesHeader = request.getHeader("X-Local-Roles") if (!userId.isNullOrBlank() && !tenantId.isNullOrBlank()) { val roles = rolesHeader?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet() ?: emptySet() diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantFilter.kt b/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantFilter.kt index ad50452..eb87636 100644 --- a/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantFilter.kt +++ b/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantFilter.kt @@ -5,12 +5,14 @@ import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.beans.factory.annotation.Value +import org.springframework.core.env.Environment import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter @Component class TenantFilter( - @Value("\${app.tenant.header:X-Tenant-Id}") private val tenantHeader: String + @Value("\${app.tenant.header:X-Tenant-Id}") private val tenantHeader: String, + private val environment: Environment ) : OncePerRequestFilter() { override fun doFilterInternal( @@ -18,8 +20,13 @@ class TenantFilter( response: HttpServletResponse, filterChain: FilterChain ) { - val tenantId = SecurityUtils.currentTenantId() - ?: request.getHeader(tenantHeader) + val tenantIdFromToken = SecurityUtils.currentTenantId() + val tenantIdFromHeader = if (environment.activeProfiles.contains("local")) { + request.getHeader(tenantHeader) + } else { + null + } + val tenantId = tenantIdFromToken ?: tenantIdFromHeader TenantContext.setTenantId(tenantId) try { filterChain.doFilter(request, response) diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/web/ProblemDetailsAdvice.kt b/back001/common/src/main/kotlin/com/mosenioring/common/web/ProblemDetailsAdvice.kt index df2193f..30c75b4 100644 --- a/back001/common/src/main/kotlin/com/mosenioring/common/web/ProblemDetailsAdvice.kt +++ b/back001/common/src/main/kotlin/com/mosenioring/common/web/ProblemDetailsAdvice.kt @@ -17,6 +17,7 @@ class ProblemDetailsAdvice { val detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST) detail.title = "Validation failed" detail.type = java.net.URI.create("https://httpstatuses.io/400") + detail.setProperty("code", "VALIDATION_FAILED") detail.setProperty("path", request.requestURI) detail.setProperty("errors", ex.bindingResult.fieldErrors.map { it.toErrorMap() }) return detail @@ -25,6 +26,7 @@ class ProblemDetailsAdvice { @ExceptionHandler(ErrorResponseException::class) fun handleErrorResponse(ex: ErrorResponseException, request: HttpServletRequest): ProblemDetail { val detail = ex.body + detail.setProperty("code", "ERROR_RESPONSE") detail.setProperty("path", request.requestURI) return detail } @@ -35,6 +37,7 @@ class ProblemDetailsAdvice { detail.title = "Invalid request" detail.detail = ex.message detail.type = java.net.URI.create("https://httpstatuses.io/400") + detail.setProperty("code", "INVALID_REQUEST") detail.setProperty("path", request.requestURI) return detail } @@ -45,6 +48,18 @@ class ProblemDetailsAdvice { detail.title = "Conflict" detail.detail = ex.message detail.type = java.net.URI.create("https://httpstatuses.io/409") + detail.setProperty("code", "CONFLICT") + detail.setProperty("path", request.requestURI) + return detail + } + + @ExceptionHandler(Exception::class) + fun handleUnexpected(ex: Exception, request: HttpServletRequest): ProblemDetail { + val detail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR) + detail.title = "Unexpected error" + detail.detail = ex.message + detail.type = java.net.URI.create("https://httpstatuses.io/500") + detail.setProperty("code", "UNEXPECTED_ERROR") detail.setProperty("path", request.requestURI) return detail } diff --git a/front001/mosenioring/README.md b/front001/mosenioring/README.md index b44f971..6e3146b 100644 --- a/front001/mosenioring/README.md +++ b/front001/mosenioring/README.md @@ -13,7 +13,24 @@ management, and a login flow ready to integrate with a Swagger/OpenAPI backend. ## Configuration -Update `lib/src/di/providers.dart` with your API base URL. +Runtime configuration is provided via `--dart-define` values. + +Auth + environment flags: +- `API_BASE_URL` (default: `http://localhost:8080`) +- `USE_LOCAL_AUTH` (`true`/`false`, default: `false`) +- `LOCAL_TENANT_ID` (default: `local-tenant`) +- `LOCAL_ROLES` (default: `CAREGIVER`) +- `KEYCLOAK_ISSUER_URI` or `KEYCLOAK_ISSUER` (default: `http://localhost:8081/realms/mosenioring`) +- `KEYCLOAK_CLIENT_ID` (default: `mosenioring-mobile`) +- `KEYCLOAK_REDIRECT_URL` (default: `com.mosenioring.app://oauth2redirect`) + +Local dev behavior: +- When `USE_LOCAL_AUTH=true`, the app keeps the email/password fields and sends + `X-Local-Email`, `X-Local-Roles`, and `X-Tenant-Id` headers to the backend. +- The backend must run with `SPRING_PROFILES_ACTIVE=local` and `ALLOW_LOCAL_AUTH=true`. + +Keycloak redirect URI to register: +- `com.mosenioring.app://oauth2redirect` (Android + iOS) ## Swagger/OpenAPI integration @@ -29,3 +46,24 @@ When the spec is ready, generate a client and replace `ApiClient` usage: flutter pub get flutter run ``` + +Quick start (Keycloak mode): +```sh +flutter run \ + --dart-define=API_BASE_URL=http://localhost:8080 \ + --dart-define=KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring \ + --dart-define=KEYCLOAK_CLIENT_ID=mosenioring-mobile \ + --dart-define=KEYCLOAK_REDIRECT_URL=com.mosenioring.app://oauth2redirect +``` + +Quick start (local auth, no Keycloak): +```sh +export SPRING_PROFILES_ACTIVE=local +export ALLOW_LOCAL_AUTH=true + +flutter run \ + --dart-define=API_BASE_URL=http://localhost:8080 \ + --dart-define=USE_LOCAL_AUTH=true \ + --dart-define=LOCAL_TENANT_ID=local-tenant \ + --dart-define=LOCAL_ROLES=CAREGIVER +``` diff --git a/front001/mosenioring/android/app/build.gradle.kts b/front001/mosenioring/android/app/build.gradle.kts index 5c38a0a..419f7d0 100644 --- a/front001/mosenioring/android/app/build.gradle.kts +++ b/front001/mosenioring/android/app/build.gradle.kts @@ -28,6 +28,8 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + manifestPlaceholders["appAuthRedirectScheme"] = "com.mosenioring.app" + manifestPlaceholders["appAuthRedirectHost"] = "oauth2redirect" } buildTypes { diff --git a/front001/mosenioring/android/app/src/main/AndroidManifest.xml b/front001/mosenioring/android/app/src/main/AndroidManifest.xml index b614cca..df0c958 100644 --- a/front001/mosenioring/android/app/src/main/AndroidManifest.xml +++ b/front001/mosenioring/android/app/src/main/AndroidManifest.xml @@ -24,6 +24,14 @@ + + + + + + diff --git a/front001/mosenioring/ios/Runner/Info.plist b/front001/mosenioring/ios/Runner/Info.plist index 5d16ae2..37dcaff 100644 --- a/front001/mosenioring/ios/Runner/Info.plist +++ b/front001/mosenioring/ios/Runner/Info.plist @@ -45,5 +45,16 @@ UIApplicationSupportsIndirectInputEvents + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.mosenioring.app + + + diff --git a/front001/mosenioring/lib/src/core/config/app_config.dart b/front001/mosenioring/lib/src/core/config/app_config.dart index 8dd4ec0..a48ab27 100644 --- a/front001/mosenioring/lib/src/core/config/app_config.dart +++ b/front001/mosenioring/lib/src/core/config/app_config.dart @@ -1,5 +1,22 @@ class AppConfig { - const AppConfig({required this.apiBaseUrl}); + const AppConfig({ + required this.apiBaseUrl, + required this.useLocalAuth, + required this.localTenantId, + required this.localRoles, + required this.keycloakIssuer, + required this.keycloakClientId, + required this.keycloakRedirectUrl, + }); final String apiBaseUrl; + final bool useLocalAuth; + final String localTenantId; + final String localRoles; + final String keycloakIssuer; + final String keycloakClientId; + final String keycloakRedirectUrl; + + String get keycloakDiscoveryUrl => + '$keycloakIssuer/.well-known/openid-configuration'; } diff --git a/front001/mosenioring/lib/src/core/network/api_client.dart b/front001/mosenioring/lib/src/core/network/api_client.dart index 7714f3d..7f9b8e4 100644 --- a/front001/mosenioring/lib/src/core/network/api_client.dart +++ b/front001/mosenioring/lib/src/core/network/api_client.dart @@ -5,17 +5,33 @@ class ApiClient { final Dio _dio; + Future> request( + String path, { + String method = 'GET', + Object? data, + Map? query, + Options? options, + }) { + final requestOptions = (options ?? Options()).copyWith(method: method); + return _dio.request( + path, + data: data, + queryParameters: query, + options: requestOptions, + ); + } + Future> get( String path, { Map? query, }) { - return _dio.get(path, queryParameters: query); + return request(path, query: query); } Future> post( String path, { Object? data, }) { - return _dio.post(path, data: data); + return request(path, method: 'POST', data: data); } } diff --git a/front001/mosenioring/lib/src/di/providers.dart b/front001/mosenioring/lib/src/di/providers.dart index f0888dd..c673bbc 100644 --- a/front001/mosenioring/lib/src/di/providers.dart +++ b/front001/mosenioring/lib/src/di/providers.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -12,7 +13,39 @@ import '../features/auth/presentation/auth_controller.dart'; import '../features/auth/presentation/auth_state.dart'; final appConfigProvider = Provider((ref) { - return const AppConfig(apiBaseUrl: 'https://api.example.com'); + const apiBaseUrl = + String.fromEnvironment('API_BASE_URL', defaultValue: 'http://localhost:8080'); + const useLocalAuth = + bool.fromEnvironment('USE_LOCAL_AUTH', defaultValue: false); + const localTenantId = + String.fromEnvironment('LOCAL_TENANT_ID', defaultValue: 'local-tenant'); + const localRoles = + String.fromEnvironment('LOCAL_ROLES', defaultValue: 'CAREGIVER'); + const keycloakIssuer = String.fromEnvironment( + 'KEYCLOAK_ISSUER', + defaultValue: '', + ); + const keycloakIssuerUri = String.fromEnvironment( + 'KEYCLOAK_ISSUER_URI', + defaultValue: 'http://localhost:8081/realms/mosenioring', + ); + final resolvedIssuer = + keycloakIssuer.isNotEmpty ? keycloakIssuer : keycloakIssuerUri; + const keycloakClientId = + String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: 'mosenioring-mobile'); + const keycloakRedirectUrl = String.fromEnvironment( + 'KEYCLOAK_REDIRECT_URL', + defaultValue: 'com.mosenioring.app://oauth2redirect', + ); + return AppConfig( + apiBaseUrl: apiBaseUrl, + useLocalAuth: useLocalAuth, + localTenantId: localTenantId, + localRoles: localRoles, + keycloakIssuer: resolvedIssuer, + keycloakClientId: keycloakClientId, + keycloakRedirectUrl: keycloakRedirectUrl, + ); }); final secureStorageProvider = Provider((ref) { @@ -23,9 +56,14 @@ final authLocalDataSourceProvider = Provider((ref) { return AuthLocalDataSource(ref.watch(secureStorageProvider)); }); +final flutterAppAuthProvider = Provider((ref) { + return const FlutterAppAuth(); +}); + final dioProvider = Provider((ref) { final config = ref.watch(appConfigProvider); final localDataSource = ref.watch(authLocalDataSourceProvider); + final authRemoteDataSource = ref.watch(authRemoteDataSourceProvider); final dio = Dio( BaseOptions( @@ -38,12 +76,49 @@ final dioProvider = Provider((ref) { dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { - final token = await localDataSource.readToken(); - if (token?.accessToken.isNotEmpty == true) { - options.headers['Authorization'] = 'Bearer ${token!.accessToken}'; + if (config.useLocalAuth) { + final session = await localDataSource.readLocalSession(); + if (session != null) { + options.headers['X-Local-Email'] = session.email; + options.headers['X-Local-Roles'] = session.roles; + options.headers['X-Tenant-Id'] = session.tenantId; + } + } else { + final token = await localDataSource.readToken(); + if (token?.accessToken.isNotEmpty == true) { + options.headers['Authorization'] = 'Bearer ${token!.accessToken}'; + } } handler.next(options); }, + onError: (error, handler) async { + if (config.useLocalAuth) { + handler.next(error); + return; + } + final response = error.response; + final requestOptions = error.requestOptions; + if (response?.statusCode != 401 || requestOptions.extra['retried'] == true) { + handler.next(error); + return; + } + final refreshToken = (await localDataSource.readToken())?.refreshToken; + if (refreshToken == null || refreshToken.isEmpty) { + handler.next(error); + return; + } + try { + final newToken = + await authRemoteDataSource.refreshToken(refreshToken: refreshToken); + await localDataSource.saveToken(newToken); + requestOptions.extra['retried'] = true; + requestOptions.headers['Authorization'] = 'Bearer ${newToken.accessToken}'; + final retryResponse = await dio.fetch(requestOptions); + handler.resolve(retryResponse); + } catch (_) { + handler.next(error); + } + }, ), ); @@ -55,7 +130,10 @@ final apiClientProvider = Provider((ref) { }); final authRemoteDataSourceProvider = Provider((ref) { - return AuthRemoteDataSource(ref.watch(dioProvider)); + return AuthRemoteDataSource( + ref.watch(flutterAppAuthProvider), + ref.watch(appConfigProvider), + ); }); final authRepositoryProvider = Provider((ref) { diff --git a/front001/mosenioring/lib/src/features/auth/data/auth_local_data_source.dart b/front001/mosenioring/lib/src/features/auth/data/auth_local_data_source.dart index f5a993a..34254e3 100644 --- a/front001/mosenioring/lib/src/features/auth/data/auth_local_data_source.dart +++ b/front001/mosenioring/lib/src/features/auth/data/auth_local_data_source.dart @@ -9,6 +9,9 @@ class AuthLocalDataSource { static const _accessTokenKey = 'access_token'; static const _refreshTokenKey = 'refresh_token'; + static const _localEmailKey = 'local_email'; + static const _localRolesKey = 'local_roles'; + static const _localTenantKey = 'local_tenant'; Future readToken() async { final accessToken = await _storage.read(key: _accessTokenKey); @@ -26,8 +29,47 @@ class AuthLocalDataSource { } } + Future saveLocalSession({ + required String email, + required String roles, + required String tenantId, + }) async { + await _storage.write(key: _localEmailKey, value: email); + await _storage.write(key: _localRolesKey, value: roles); + await _storage.write(key: _localTenantKey, value: tenantId); + } + + Future readLocalSession() async { + final email = await _storage.read(key: _localEmailKey); + final roles = await _storage.read(key: _localRolesKey); + final tenantId = await _storage.read(key: _localTenantKey); + if (email == null || tenantId == null) { + return null; + } + return LocalAuthSession( + email: email, + roles: roles?.isNotEmpty == true ? roles! : 'CAREGIVER', + tenantId: tenantId, + ); + } + Future clear() async { await _storage.delete(key: _accessTokenKey); await _storage.delete(key: _refreshTokenKey); + await _storage.delete(key: _localEmailKey); + await _storage.delete(key: _localRolesKey); + await _storage.delete(key: _localTenantKey); } } + +class LocalAuthSession { + const LocalAuthSession({ + required this.email, + required this.roles, + required this.tenantId, + }); + + final String email; + final String roles; + final String tenantId; +} diff --git a/front001/mosenioring/lib/src/features/auth/data/auth_remote_data_source.dart b/front001/mosenioring/lib/src/features/auth/data/auth_remote_data_source.dart index 55a6ece..e6833b7 100644 --- a/front001/mosenioring/lib/src/features/auth/data/auth_remote_data_source.dart +++ b/front001/mosenioring/lib/src/features/auth/data/auth_remote_data_source.dart @@ -1,38 +1,62 @@ -import 'package:dio/dio.dart'; +import 'package:flutter_appauth/flutter_appauth.dart'; +import '../../../core/config/app_config.dart'; import '../domain/models/auth_token.dart'; class AuthRemoteDataSource { - AuthRemoteDataSource(this._dio); + AuthRemoteDataSource(this._appAuth, this._config); - final Dio _dio; + final FlutterAppAuth _appAuth; + final AppConfig _config; Future login({ required String email, required String password, }) async { - final response = await _dio.post( - '/auth/login', - data: { - 'email': email, - 'password': password, - }, + final _ = password; + final response = await _appAuth.authorizeAndExchangeCode( + AuthorizationTokenRequest( + _config.keycloakClientId, + _config.keycloakRedirectUrl, + discoveryUrl: _config.keycloakDiscoveryUrl, + loginHint: email, + promptValues: const ['login'], + scopes: const ['openid', 'profile', 'offline_access'], + ), ); - final data = response.data; - if (data is! Map) { - throw Exception('Unexpected login response'); - } - - final accessToken = - (data['access_token'] as String?) ?? (data['token'] as String?) ?? ''; - if (accessToken.isEmpty) { + final accessToken = response?.accessToken; + if (accessToken == null || accessToken.isEmpty) { throw Exception('Missing access token'); } return AuthToken( accessToken: accessToken, - refreshToken: data['refresh_token'] as String?, + refreshToken: response?.refreshToken, + ); + } + + Future refreshToken({ + required String refreshToken, + }) async { + final response = await _appAuth.token( + TokenRequest( + _config.keycloakClientId, + _config.keycloakRedirectUrl, + discoveryUrl: _config.keycloakDiscoveryUrl, + refreshToken: refreshToken, + scopes: const ['openid', 'profile', 'offline_access'], + ), + ); + + final accessToken = response?.accessToken; + if (accessToken == null || accessToken.isEmpty) { + throw Exception('Missing access token'); + } + + return AuthToken( + accessToken: accessToken, + refreshToken: response?.refreshToken ?? refreshToken, ); } } diff --git a/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart b/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart index 3ca21f3..073c85b 100644 --- a/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart +++ b/front001/mosenioring/lib/src/features/auth/presentation/auth_controller.dart @@ -1,7 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../di/providers.dart'; +import '../data/auth_local_data_source.dart'; import '../domain/auth_repository.dart'; +import '../domain/models/auth_token.dart'; import 'auth_state.dart'; class AuthController extends Notifier { @@ -12,9 +14,19 @@ class AuthController extends Notifier { } AuthRepository get _repository => ref.watch(authRepositoryProvider); + AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider); Future _bootstrap() async { state = state.copyWith(isLoading: true, errorMessage: null); + final config = ref.read(appConfigProvider); + if (config.useLocalAuth) { + final localSession = await _localDataSource.readLocalSession(); + state = AuthState( + isLoading: false, + token: localSession != null ? const AuthToken(accessToken: 'local') : null, + ); + return; + } final token = await _repository.getSavedToken(); state = AuthState(isLoading: false, token: token); } @@ -25,12 +37,24 @@ class AuthController extends Notifier { }) async { state = state.copyWith(isLoading: true, errorMessage: null); try { + final config = ref.read(appConfigProvider); + if (config.useLocalAuth) { + await _localDataSource.saveLocalSession( + email: email, + roles: config.localRoles, + tenantId: config.localTenantId, + ); + const token = AuthToken(accessToken: 'local'); + await _repository.persistToken(token); + state = const AuthState(isLoading: false, token: token); + return; + } final token = await _repository.login(email: email, password: password); state = AuthState(isLoading: false, token: token); } catch (error) { state = state.copyWith( isLoading: false, - errorMessage: error.toString(), + errorMessage: _friendlyError(error), ); } } @@ -39,4 +63,12 @@ class AuthController extends Notifier { await _repository.logout(); state = const AuthState(); } + + String _friendlyError(Object error) { + final message = error.toString(); + if (message.startsWith('Exception: ')) { + return message.substring('Exception: '.length); + } + return message; + } } diff --git a/front001/mosenioring/lib/src/features/auth/presentation/login_page.dart b/front001/mosenioring/lib/src/features/auth/presentation/login_page.dart index 57c98f5..c93e8e2 100644 --- a/front001/mosenioring/lib/src/features/auth/presentation/login_page.dart +++ b/front001/mosenioring/lib/src/features/auth/presentation/login_page.dart @@ -42,6 +42,7 @@ class _LoginPageState extends ConsumerState { @override Widget build(BuildContext context) { final authState = ref.watch(authControllerProvider); + final config = ref.watch(appConfigProvider); final l10n = AppLocalizations.of(context)!; return Scaffold( @@ -99,6 +100,13 @@ class _LoginPageState extends ConsumerState { ), ), ], + if (config.useLocalAuth) ...[ + const SizedBox(height: 12), + Text( + 'Local dev mode enabled', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ], ), ), diff --git a/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift b/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift index 61d01d0..d836919 100644 --- a/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/front001/mosenioring/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,12 @@ import FlutterMacOS import Foundation +import flutter_appauth import flutter_secure_storage_darwin import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) } diff --git a/front001/mosenioring/pubspec.yaml b/front001/mosenioring/pubspec.yaml index 648c747..5fb2a3e 100644 --- a/front001/mosenioring/pubspec.yaml +++ b/front001/mosenioring/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 dio: ^5.7.0 + flutter_appauth: ^11.0.0 flutter_riverpod: ^3.1.0 flutter_secure_storage: ^10.0.0 go_router: ^17.0.1