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 - Java 21
- Docker + Docker Compose - Docker + Docker Compose
## Local Dev ## Local run
1) Start dependencies: 1) Start dependencies:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
2) Run the API (JWT resource server): 2) Run the API (local profile, local auth enabled):
```bash ```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 ```bash
./gradlew :workers:notification-worker:bootRun ./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 ## Auth
- The backend is a JWT resource server and does not handle user passwords. - The backend is a JWT resource server and does not handle user passwords.
- Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local` - Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local`

View file

@ -24,6 +24,7 @@ class SecurityConfig {
.csrf { it.disable() } .csrf { it.disable() }
.authorizeHttpRequests { .authorizeHttpRequests {
it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() 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.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN")
it.anyRequest().authenticated() 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, "directAccessGrantsEnabled": true,
"standardFlowEnabled": true, "standardFlowEnabled": true,
"redirectUris": ["*"] "redirectUris": ["*"]
},
{
"clientId": "mosenioring-mobile",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": false,
"standardFlowEnabled": true,
"redirectUris": ["com.mosenioring.app://oauth2redirect"],
"webOrigins": ["*"]
} }
], ],
"users": [ "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. Runtime configuration is provided via `--dart-define` values.
Auth + environment flags: Auth + environment flags:
- `API_BASE_URL` (default: `http://localhost:8080`) - `API_BASE_URL` (required)
- `USE_LOCAL_AUTH` (`true`/`false`, default: `false`) - `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`) - `LOCAL_ROLES` (default: `CAREGIVER`)
- `KEYCLOAK_ISSUER_URI` or `KEYCLOAK_ISSUER` (default: `http://localhost:8081/realms/mosenioring`) - `KEYCLOAK_ISSUER_URI` or `KEYCLOAK_ISSUER` (required when `USE_LOCAL_AUTH=false`)
- `KEYCLOAK_CLIENT_ID` (default: `mosenioring-mobile`) - `KEYCLOAK_CLIENT_ID` (required when `USE_LOCAL_AUTH=false`)
- `KEYCLOAK_REDIRECT_URL` (default: `com.mosenioring.app://oauth2redirect`) - `KEYCLOAK_REDIRECT_URL` (required when `USE_LOCAL_AUTH=false`)
Local dev behavior: Local dev behavior:
- When `USE_LOCAL_AUTH=true`, the app keeps the email/password fields and sends - 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. `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`. - 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: Keycloak redirect URI to register:
- `com.mosenioring.app://oauth2redirect` (Android + iOS) - `com.mosenioring.app://oauth2redirect` (Android + iOS)
@ -47,11 +48,24 @@ flutter pub get
flutter run flutter run
``` ```
Quick start (Keycloak mode): ## Local run
```sh ```sh
flutter run \ flutter run \
--dart-define=API_BASE_URL=http://localhost:8080 \ --dart-define=API_BASE_URL=http://10.0.2.2:8080 \
--dart-define=KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring \ --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_CLIENT_ID=mosenioring-mobile \
--dart-define=KEYCLOAK_REDIRECT_URL=com.mosenioring.app://oauth2redirect --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 SPRING_PROFILES_ACTIVE=local
export ALLOW_LOCAL_AUTH=true export ALLOW_LOCAL_AUTH=true
flutter run \ flutter run -d <device_id> \
--dart-define=API_BASE_URL=http://localhost:8080 \ --dart-define=API_BASE_URL=http://10.0.2.2:8080 \
--dart-define=USE_LOCAL_AUTH=true \ --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 --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 INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="n"/>
</manifest> </manifest>

View file

@ -2,7 +2,9 @@
<application <application
android:label="mosenioring" android:label="mosenioring"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@ -29,8 +31,8 @@
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data <data
android:scheme="${appAuthRedirectScheme}" android:scheme="com.mosenioring.app"
android:host="${appAuthRedirectHost}"/> android:host="oauth2redirect"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- 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; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; 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> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>

View file

@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
class AppConfig { class AppConfig {
const AppConfig({ const AppConfig({
required this.apiBaseUrl, required this.apiBaseUrl,
@ -19,4 +21,37 @@ class AppConfig {
String get keycloakDiscoveryUrl => String get keycloakDiscoveryUrl =>
'$keycloakIssuer/.well-known/openid-configuration'; '$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'; import '../features/auth/presentation/auth_state.dart';
final appConfigProvider = Provider<AppConfig>((ref) { final appConfigProvider = Provider<AppConfig>((ref) {
const apiBaseUrl = const apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
String.fromEnvironment('API_BASE_URL', defaultValue: 'http://localhost:8080');
const useLocalAuth = const useLocalAuth =
bool.fromEnvironment('USE_LOCAL_AUTH', defaultValue: false); bool.fromEnvironment('USE_LOCAL_AUTH', defaultValue: false);
const localTenantId = const localTenantId = String.fromEnvironment(
String.fromEnvironment('LOCAL_TENANT_ID', defaultValue: 'local-tenant'); 'LOCAL_TENANT_ID',
defaultValue: '11111111-1111-1111-1111-111111111111',
);
const localRoles = const localRoles =
String.fromEnvironment('LOCAL_ROLES', defaultValue: 'CAREGIVER'); String.fromEnvironment('LOCAL_ROLES', defaultValue: 'CAREGIVER');
const keycloakIssuer = String.fromEnvironment( const keycloakIssuer = String.fromEnvironment('KEYCLOAK_ISSUER', defaultValue: '');
'KEYCLOAK_ISSUER', const keycloakIssuerUri =
defaultValue: '', String.fromEnvironment('KEYCLOAK_ISSUER_URI', defaultValue: '');
);
const keycloakIssuerUri = String.fromEnvironment(
'KEYCLOAK_ISSUER_URI',
defaultValue: 'http://localhost:8081/realms/mosenioring',
);
final resolvedIssuer = final resolvedIssuer =
keycloakIssuer.isNotEmpty ? keycloakIssuer : keycloakIssuerUri; keycloakIssuer.isNotEmpty ? keycloakIssuer : keycloakIssuerUri;
const keycloakClientId = const keycloakClientId =
String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: 'mosenioring-mobile'); String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: '');
const keycloakRedirectUrl = String.fromEnvironment( const keycloakRedirectUrl = String.fromEnvironment(
'KEYCLOAK_REDIRECT_URL', 'KEYCLOAK_REDIRECT_URL',
defaultValue: 'com.mosenioring.app://oauth2redirect', defaultValue: '',
); );
return AppConfig( return AppConfig(
apiBaseUrl: apiBaseUrl, apiBaseUrl: apiBaseUrl,

View file

@ -14,11 +14,17 @@ class AuthRemoteDataSource {
required String password, required String password,
}) async { }) async {
final _ = password; 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( final response = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest( AuthorizationTokenRequest(
_config.keycloakClientId, _config.keycloakClientId,
_config.keycloakRedirectUrl, _config.keycloakRedirectUrl,
discoveryUrl: _config.keycloakDiscoveryUrl, discoveryUrl: _config.keycloakDiscoveryUrl,
allowInsecureConnections: _config.allowInsecureConnections,
loginHint: email, loginHint: email,
promptValues: const ['login'], promptValues: const ['login'],
scopes: const ['openid', 'profile', 'offline_access'], scopes: const ['openid', 'profile', 'offline_access'],
@ -39,11 +45,17 @@ class AuthRemoteDataSource {
Future<AuthToken> refreshToken({ Future<AuthToken> refreshToken({
required String refreshToken, required String refreshToken,
}) async { }) 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( final response = await _appAuth.token(
TokenRequest( TokenRequest(
_config.keycloakClientId, _config.keycloakClientId,
_config.keycloakRedirectUrl, _config.keycloakRedirectUrl,
discoveryUrl: _config.keycloakDiscoveryUrl, discoveryUrl: _config.keycloakDiscoveryUrl,
allowInsecureConnections: _config.allowInsecureConnections,
refreshToken: refreshToken, refreshToken: refreshToken,
scopes: const ['openid', 'profile', 'offline_access'], scopes: const ['openid', 'profile', 'offline_access'],
), ),

View file

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

View file

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

View file

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