doesnt work

This commit is contained in:
oskar 2026-01-12 18:38:15 +01:00
parent 90d34aed42
commit d5b28ad972
53 changed files with 524 additions and 41 deletions

View file

@ -6,25 +6,39 @@ Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-care
- Java 21
- Docker + Docker Compose
## Local Dev
## Local run
1) Start dependencies:
```bash
docker compose up -d
```
2) Run the API (JWT resource server):
2) Run the API (local profile, local auth enabled):
```bash
./gradlew :app:bootRun -Dspring.profiles.active=local
SPRING_PROFILES_ACTIVE=local \
ALLOW_LOCAL_AUTH=true \
./gradlew :app:bootRun
```
3) Run the worker:
```bash
SPRING_PROFILES_ACTIVE=dev \
ALLOW_LOCAL_AUTH=false \
./gradlew :app:bootRun
```
3) (Optional) Run the worker:
```bash
./gradlew :workers:notification-worker:bootRun
```
Required env flags (local/dev):
- `SPRING_PROFILES_ACTIVE=local`
- `ALLOW_LOCAL_AUTH=true` (enables local auth headers)
- `KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring` (if using Keycloak)
- Frontend should set `USE_LOCAL_AUTH=true` when using local auth headers.
## 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`

View file

@ -24,6 +24,7 @@ class SecurityConfig {
.csrf { it.disable() }
.authorizeHttpRequests {
it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
it.requestMatchers("/api/v1/demo").permitAll()
it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN")
it.anyRequest().authenticated()
}

View file

@ -0,0 +1,22 @@
package com.mosenioring.app.demo
object DemoDataIds {
const val tenantId = "11111111-1111-1111-1111-111111111111"
const val patientId = "22222222-2222-2222-2222-222222222222"
const val patientUserId = "33333333-3333-3333-3333-333333333333"
const val caregiverUserId = "44444444-4444-4444-4444-444444444444"
const val doctorUserId = "55555555-5555-5555-5555-555555555555"
const val patientCaregiverId = "66666666-6666-6666-6666-666666666666"
const val patientDoctorId = "77777777-7777-7777-7777-777777777777"
const val medicationPlanId = "88888888-8888-8888-8888-888888888888"
const val testOrderId = "99999999-9999-9999-9999-999999999999"
const val messageOneId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
const val messageTwoId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
const val fileId = "cccccccc-cccc-cccc-cccc-cccccccccccc"
const val auditEventOneId = "dddddddd-dddd-dddd-dddd-dddddddddddd"
const val auditEventTwoId = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"
const val auditEventThreeId = "ffffffff-ffff-ffff-ffff-ffffffffffff"
}

View file

@ -0,0 +1,31 @@
package com.mosenioring.app.demo
import org.springframework.context.annotation.Profile
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
data class DemoIdsResponse(
val tenantId: String,
val patientId: String,
val userIds: Map<String, String>
)
@RestController
@Profile("local")
@RequestMapping("/api/v1/demo")
class LocalDemoController {
@GetMapping
fun demo(): DemoIdsResponse {
return DemoIdsResponse(
tenantId = DemoDataIds.tenantId,
patientId = DemoDataIds.patientId,
userIds = mapOf(
"patient" to DemoDataIds.patientUserId,
"caregiver" to DemoDataIds.caregiverUserId,
"doctor" to DemoDataIds.doctorUserId
)
)
}
}

View file

@ -0,0 +1,284 @@
package com.mosenioring.app.demo
import com.mosenioring.audit.AuditEvent
import com.mosenioring.audit.AuditRepository
import com.mosenioring.clinical.MedicationPlan
import com.mosenioring.clinical.TestOrder
import com.mosenioring.clinical.repo.MedicationPlanRepository
import com.mosenioring.clinical.repo.TestOrderRepository
import com.mosenioring.common.BaseEntity
import com.mosenioring.identity.Patient
import com.mosenioring.identity.PatientCaregiver
import com.mosenioring.identity.PatientDoctor
import com.mosenioring.identity.Tenant
import com.mosenioring.identity.User
import com.mosenioring.identity.repo.PatientCaregiverRepository
import com.mosenioring.identity.repo.PatientDoctorRepository
import com.mosenioring.identity.repo.PatientRepository
import com.mosenioring.identity.repo.TenantRepository
import com.mosenioring.identity.repo.UserRepository
import com.mosenioring.integrations.FileMetadata
import com.mosenioring.integrations.repo.FileRepository
import com.mosenioring.messaging.Message
import com.mosenioring.messaging.repo.MessageRepository
import org.slf4j.LoggerFactory
import org.springframework.boot.CommandLineRunner
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
@Component
@Profile("local")
class LocalDemoSeeder(
private val tenantRepository: TenantRepository,
private val userRepository: UserRepository,
private val patientRepository: PatientRepository,
private val patientCaregiverRepository: PatientCaregiverRepository,
private val patientDoctorRepository: PatientDoctorRepository,
private val medicationPlanRepository: MedicationPlanRepository,
private val testOrderRepository: TestOrderRepository,
private val messageRepository: MessageRepository,
private val fileRepository: FileRepository,
private val auditRepository: AuditRepository
) : CommandLineRunner {
private val logger = LoggerFactory.getLogger(LocalDemoSeeder::class.java)
override fun run(args: Array<String>) {
var created = 0
if (!tenantRepository.existsById(DemoDataIds.tenantId)) {
tenantRepository.save(
seedDefaults(Tenant(DemoDataIds.tenantId, "Local Demo Tenant"))
)
created += 1
}
created += saveIfMissing(
DemoDataIds.patientUserId,
userRepository
) {
seedDefaults(
User(
DemoDataIds.patientUserId,
"patient@local.test",
"PATIENT",
"ACTIVE"
)
)
}
created += saveIfMissing(
DemoDataIds.caregiverUserId,
userRepository
) {
seedDefaults(
User(
DemoDataIds.caregiverUserId,
"caregiver@local.test",
"CAREGIVER",
"ACTIVE"
)
)
}
created += saveIfMissing(
DemoDataIds.doctorUserId,
userRepository
) {
seedDefaults(
User(
DemoDataIds.doctorUserId,
"doctor@local.test",
"DOCTOR",
"ACTIVE"
)
)
}
created += saveIfMissing(
DemoDataIds.patientId,
patientRepository
) {
seedDefaults(
Patient(
DemoDataIds.patientId,
"Alex Patient"
)
)
}
created += saveIfMissing(
DemoDataIds.patientCaregiverId,
patientCaregiverRepository
) {
seedDefaults(
PatientCaregiver(
DemoDataIds.patientCaregiverId,
DemoDataIds.patientId,
DemoDataIds.caregiverUserId
)
)
}
created += saveIfMissing(
DemoDataIds.patientDoctorId,
patientDoctorRepository
) {
seedDefaults(
PatientDoctor(
DemoDataIds.patientDoctorId,
DemoDataIds.patientId,
DemoDataIds.doctorUserId
)
)
}
created += saveIfMissing(
DemoDataIds.medicationPlanId,
medicationPlanRepository
) {
seedDefaults(
MedicationPlan(
DemoDataIds.medicationPlanId,
DemoDataIds.patientId,
"Morning: 5mg Samplex, Evening: 10mg Calmex"
)
)
}
created += saveIfMissing(
DemoDataIds.testOrderId,
testOrderRepository
) {
seedDefaults(
TestOrder(
DemoDataIds.testOrderId,
DemoDataIds.patientId,
"Blood pressure check",
"ORDERED"
)
)
}
created += saveIfMissing(
DemoDataIds.messageOneId,
messageRepository
) {
seedDefaults(
Message(
DemoDataIds.messageOneId,
DemoDataIds.patientId,
DemoDataIds.caregiverUserId,
"Morning meds taken."
)
)
}
created += saveIfMissing(
DemoDataIds.messageTwoId,
messageRepository
) {
seedDefaults(
Message(
DemoDataIds.messageTwoId,
DemoDataIds.patientId,
DemoDataIds.doctorUserId,
"Thanks! Please record evening dose too."
)
)
}
created += saveIfMissing(
DemoDataIds.fileId,
fileRepository
) {
seedDefaults(
FileMetadata(
DemoDataIds.fileId,
DemoDataIds.patientId,
"care-plan.pdf",
"application/pdf",
"demo/care-plan.pdf"
)
)
}
created += saveIfMissing(
DemoDataIds.auditEventOneId,
auditRepository
) {
seedDefaults(
AuditEvent(
DemoDataIds.auditEventOneId,
DemoDataIds.caregiverUserId,
"MESSAGE_SENT",
"message",
DemoDataIds.messageOneId,
DemoDataIds.patientId
)
)
}
created += saveIfMissing(
DemoDataIds.auditEventTwoId,
auditRepository
) {
seedDefaults(
AuditEvent(
DemoDataIds.auditEventTwoId,
DemoDataIds.doctorUserId,
"MESSAGE_SENT",
"message",
DemoDataIds.messageTwoId,
DemoDataIds.patientId
)
)
}
created += saveIfMissing(
DemoDataIds.auditEventThreeId,
auditRepository
) {
seedDefaults(
AuditEvent(
DemoDataIds.auditEventThreeId,
DemoDataIds.doctorUserId,
"MEDICATION_PLAN_UPDATED",
"medication_plan",
DemoDataIds.medicationPlanId,
DemoDataIds.patientId
)
)
}
logger.info(
"Local demo data ready (created {} records): tenantId={}, patientId={}, users={{patient={}, caregiver={}, doctor={}}}, emails={{patient@local.test, caregiver@local.test, doctor@local.test}}",
created,
DemoDataIds.tenantId,
DemoDataIds.patientId,
DemoDataIds.patientUserId,
DemoDataIds.caregiverUserId,
DemoDataIds.doctorUserId
)
}
private fun <T : BaseEntity> seedDefaults(entity: T): T {
entity.tenantId = DemoDataIds.tenantId
entity.createdBy = "seed"
entity.updatedBy = "seed"
return entity
}
private fun <T : BaseEntity> saveIfMissing(
id: String,
repository: org.springframework.data.jpa.repository.JpaRepository<T, String>,
build: () -> T
): Int {
return if (repository.existsById(id)) {
0
} else {
repository.save(build())
1
}
}
}

View file

@ -17,6 +17,15 @@
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"redirectUris": ["*"]
},
{
"clientId": "mosenioring-mobile",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": false,
"standardFlowEnabled": true,
"redirectUris": ["com.mosenioring.app://oauth2redirect"],
"webOrigins": ["*"]
}
],
"users": [

View file

@ -16,18 +16,19 @@ management, and a login flow ready to integrate with a Swagger/OpenAPI backend.
Runtime configuration is provided via `--dart-define` values.
Auth + environment flags:
- `API_BASE_URL` (default: `http://localhost:8080`)
- `API_BASE_URL` (required)
- `USE_LOCAL_AUTH` (`true`/`false`, default: `false`)
- `LOCAL_TENANT_ID` (default: `local-tenant`)
- `LOCAL_TENANT_ID` (default: `11111111-1111-1111-1111-111111111111`)
- `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`)
- `KEYCLOAK_ISSUER_URI` or `KEYCLOAK_ISSUER` (required when `USE_LOCAL_AUTH=false`)
- `KEYCLOAK_CLIENT_ID` (required when `USE_LOCAL_AUTH=false`)
- `KEYCLOAK_REDIRECT_URL` (required when `USE_LOCAL_AUTH=false`)
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 login via `flutter_appauth` is supported only on Android/iOS.
Keycloak redirect URI to register:
- `com.mosenioring.app://oauth2redirect` (Android + iOS)
@ -47,11 +48,24 @@ flutter pub get
flutter run
```
Quick start (Keycloak mode):
## Local run
```sh
flutter run \
--dart-define=API_BASE_URL=http://localhost:8080 \
--dart-define=KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring \
--dart-define=API_BASE_URL=http://10.0.2.2:8080 \
--dart-define=USE_LOCAL_AUTH=true
```
Android emulator note:
- Use `10.0.2.2` instead of `localhost` for `API_BASE_URL` and `KEYCLOAK_ISSUER_URI`.
- `http://` Keycloak issuers are allowed for local dev; the app enables insecure connections automatically for non-HTTPS issuers.
- iOS dev builds allow HTTP via App Transport Security; tighten this for production.
Quick start (Keycloak mode):
```sh
flutter run -d emulator-5554 \
--dart-define=API_BASE_URL=http://10.0.2.2:8080 \
--dart-define=KEYCLOAK_ISSUER_URI=http://10.0.2.2:8081/realms/mosenioring \
--dart-define=KEYCLOAK_CLIENT_ID=mosenioring-mobile \
--dart-define=KEYCLOAK_REDIRECT_URL=com.mosenioring.app://oauth2redirect
```
@ -61,9 +75,18 @@ Quick start (local auth, no Keycloak):
export SPRING_PROFILES_ACTIVE=local
export ALLOW_LOCAL_AUTH=true
flutter run \
--dart-define=API_BASE_URL=http://localhost:8080 \
flutter run -d <device_id> \
--dart-define=API_BASE_URL=http://10.0.2.2:8080 \
--dart-define=USE_LOCAL_AUTH=true \
--dart-define=LOCAL_TENANT_ID=local-tenant \
--dart-define=LOCAL_TENANT_ID=11111111-1111-1111-1111-111111111111 \
--dart-define=LOCAL_ROLES=CAREGIVER
```
Redirect configuration:
- Android: `android/app/src/main/AndroidManifest.xml` includes an intent-filter for `com.mosenioring.app://oauth2redirect`.
- iOS: `ios/Runner/Info.plist` registers `com.mosenioring.app` under `CFBundleURLTypes`.
Launcher icons:
```sh
flutter pub run flutter_launcher_icons
```

View file

@ -1,7 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="n"/>
</manifest>

View file

@ -2,7 +2,9 @@
<application
android:label="mosenioring"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
@ -29,8 +31,8 @@
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="${appAuthRedirectScheme}"
android:host="${appAuthRedirectHost}"/>
android:scheme="com.mosenioring.app"
android:host="oauth2redirect"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0D3B3E</color>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 623 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 623 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -24,6 +24,11 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View file

@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
class AppConfig {
const AppConfig({
required this.apiBaseUrl,
@ -19,4 +21,37 @@ class AppConfig {
String get keycloakDiscoveryUrl =>
'$keycloakIssuer/.well-known/openid-configuration';
bool get allowInsecureConnections =>
keycloakIssuer.trim().startsWith('http://');
bool get isAppAuthSupportedPlatform {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
}
String? get validationError {
if (apiBaseUrl.trim().isEmpty) {
return 'Missing API_BASE_URL. Provide it via --dart-define=API_BASE_URL=...';
}
if (useLocalAuth) {
return null;
}
if (!isAppAuthSupportedPlatform) {
return 'Keycloak login is supported only on Android/iOS. Use USE_LOCAL_AUTH=true for desktop/web.';
}
if (keycloakIssuer.trim().isEmpty) {
return 'Missing KEYCLOAK_ISSUER_URI or KEYCLOAK_ISSUER. Provide it via --dart-define.';
}
if (keycloakClientId.trim().isEmpty) {
return 'Missing KEYCLOAK_CLIENT_ID. Provide it via --dart-define.';
}
if (keycloakRedirectUrl.trim().isEmpty) {
return 'Missing KEYCLOAK_REDIRECT_URL. Provide it via --dart-define.';
}
return null;
}
}

View file

@ -13,29 +13,25 @@ import '../features/auth/presentation/auth_controller.dart';
import '../features/auth/presentation/auth_state.dart';
final appConfigProvider = Provider<AppConfig>((ref) {
const apiBaseUrl =
String.fromEnvironment('API_BASE_URL', defaultValue: 'http://localhost:8080');
const apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
const useLocalAuth =
bool.fromEnvironment('USE_LOCAL_AUTH', defaultValue: false);
const localTenantId =
String.fromEnvironment('LOCAL_TENANT_ID', defaultValue: 'local-tenant');
const localTenantId = String.fromEnvironment(
'LOCAL_TENANT_ID',
defaultValue: '11111111-1111-1111-1111-111111111111',
);
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',
);
const keycloakIssuer = String.fromEnvironment('KEYCLOAK_ISSUER', defaultValue: '');
const keycloakIssuerUri =
String.fromEnvironment('KEYCLOAK_ISSUER_URI', defaultValue: '');
final resolvedIssuer =
keycloakIssuer.isNotEmpty ? keycloakIssuer : keycloakIssuerUri;
const keycloakClientId =
String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: 'mosenioring-mobile');
String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: '');
const keycloakRedirectUrl = String.fromEnvironment(
'KEYCLOAK_REDIRECT_URL',
defaultValue: 'com.mosenioring.app://oauth2redirect',
defaultValue: '',
);
return AppConfig(
apiBaseUrl: apiBaseUrl,

View file

@ -14,11 +14,17 @@ class AuthRemoteDataSource {
required String password,
}) async {
final _ = password;
if (!_config.isAppAuthSupportedPlatform) {
throw Exception(
'Keycloak login is supported only on Android/iOS. Use USE_LOCAL_AUTH=true for desktop/web.',
);
}
final response = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
_config.keycloakClientId,
_config.keycloakRedirectUrl,
discoveryUrl: _config.keycloakDiscoveryUrl,
allowInsecureConnections: _config.allowInsecureConnections,
loginHint: email,
promptValues: const ['login'],
scopes: const ['openid', 'profile', 'offline_access'],
@ -39,11 +45,17 @@ class AuthRemoteDataSource {
Future<AuthToken> refreshToken({
required String refreshToken,
}) async {
if (!_config.isAppAuthSupportedPlatform) {
throw Exception(
'Keycloak refresh is supported only on Android/iOS. Use USE_LOCAL_AUTH=true for desktop/web.',
);
}
final response = await _appAuth.token(
TokenRequest(
_config.keycloakClientId,
_config.keycloakRedirectUrl,
discoveryUrl: _config.keycloakDiscoveryUrl,
allowInsecureConnections: _config.allowInsecureConnections,
refreshToken: refreshToken,
scopes: const ['openid', 'profile', 'offline_access'],
),

View file

@ -19,6 +19,11 @@ class AuthController extends Notifier<AuthState> {
Future<void> _bootstrap() async {
state = state.copyWith(isLoading: true, errorMessage: null);
final config = ref.read(appConfigProvider);
final configError = config.validationError;
if (configError != null) {
state = state.copyWith(isLoading: false, errorMessage: configError);
return;
}
if (config.useLocalAuth) {
final localSession = await _localDataSource.readLocalSession();
state = AuthState(
@ -38,6 +43,11 @@ class AuthController extends Notifier<AuthState> {
state = state.copyWith(isLoading: true, errorMessage: null);
try {
final config = ref.read(appConfigProvider);
final configError = config.validationError;
if (configError != null) {
state = state.copyWith(isLoading: false, errorMessage: configError);
return;
}
if (config.useLocalAuth) {
await _localDataSource.saveLocalSession(
email: email,

View file

@ -44,6 +44,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
final authState = ref.watch(authControllerProvider);
final config = ref.watch(appConfigProvider);
final l10n = AppLocalizations.of(context)!;
final configError = config.validationError;
return Scaffold(
appBar: AppBar(title: Text(l10n.signInTitle)),
@ -55,6 +56,12 @@ class _LoginPageState extends ConsumerState<LoginPage> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/logo.png',
height: 96,
width: 96,
),
const SizedBox(height: 16),
Text(
l10n.welcomeTitle,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
@ -81,7 +88,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: authState.isLoading ? null : _submit,
onPressed: authState.isLoading || configError != null ? null : _submit,
child: authState.isLoading
? const SizedBox(
width: 20,
@ -91,10 +98,11 @@ class _LoginPageState extends ConsumerState<LoginPage> {
: Text(l10n.loginButton),
),
),
if (authState.errorMessage != null) ...[
if (authState.errorMessage != null || configError != null) ...[
const SizedBox(height: 12),
Text(
authState.errorMessage!,
authState.errorMessage ?? configError!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),

View file

@ -53,6 +53,7 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@ -67,9 +68,9 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/logo.png
- assets/launcher/icon.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
@ -96,3 +97,10 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_launcher_icons:
android: true
ios: true
image_path: assets/launcher/icon.png
adaptive_icon_background: "#0D3B3E"
adaptive_icon_foreground: assets/launcher/icon.png