diff --git a/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/Device.kt b/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/Device.kt index 292e21ab..f6faacdb 100644 --- a/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/Device.kt +++ b/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/Device.kt @@ -10,13 +10,22 @@ import kotlin.time.Instant data class Device( val id: UUID = UUID.randomUUID(), val name: String, + val expectedName: String? = null, // Falls vom Master vor-registriert val securityKeyHash: String, // Gehasht für Sicherheit val role: DeviceRole = DeviceRole.CLIENT, val lastSyncAt: Instant? = null, + val isOnline: Boolean = false, + val isSynchronized: Boolean = true, val createdAt: Instant, val updatedAt: Instant = createdAt ) enum class DeviceRole { - MASTER, CLIENT + MASTER, + CLIENT, + RICHTER, + ZEITNEHMER, + STALLMEISTER, + ANZEIGE, + PARCOURS_CHEF } diff --git a/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/DeviceTable.kt b/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/DeviceTable.kt index 67a768bb..be1bb63b 100644 --- a/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/DeviceTable.kt +++ b/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/DeviceTable.kt @@ -13,10 +13,13 @@ object DeviceTable : Table("identity_devices") { override val primaryKey = PrimaryKey(id) val name = varchar("name", 100).uniqueIndex() + val expectedName = varchar("expected_name", 100).nullable() val securityKeyHash = varchar("security_key_hash", 255) val role = enumerationByName("role", 20, DeviceRole::class) val lastSyncAt = timestamp("last_sync_at").nullable() + val isOnline = bool("is_online").default(false) + val isSynchronized = bool("is_synchronized").default(true) val createdAt = timestamp("created_at") val updatedAt = timestamp("updated_at") } diff --git a/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ExposedDeviceRepository.kt b/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ExposedDeviceRepository.kt index 57ef8bde..a58a6be0 100644 --- a/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ExposedDeviceRepository.kt +++ b/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ExposedDeviceRepository.kt @@ -33,18 +33,24 @@ class ExposedDeviceRepository : DeviceRepository { if (existing != null) { DeviceTable.update({ DeviceTable.id eq device.id }) { it[name] = device.name + it[expectedName] = device.expectedName it[securityKeyHash] = device.securityKeyHash it[role] = device.role it[lastSyncAt] = device.lastSyncAt + it[isOnline] = device.isOnline + it[isSynchronized] = device.isSynchronized it[updatedAt] = now } } else { DeviceTable.insert { it[id] = device.id it[name] = device.name + it[expectedName] = device.expectedName it[securityKeyHash] = device.securityKeyHash it[role] = device.role it[lastSyncAt] = device.lastSyncAt + it[isOnline] = device.isOnline + it[isSynchronized] = device.isSynchronized it[createdAt] = device.createdAt it[updatedAt] = now } @@ -62,9 +68,12 @@ class ExposedDeviceRepository : DeviceRepository { private fun rowToDevice(row: ResultRow): Device = Device( id = row[DeviceTable.id], name = row[DeviceTable.name], + expectedName = row[DeviceTable.expectedName], securityKeyHash = row[DeviceTable.securityKeyHash], role = row[DeviceTable.role], lastSyncAt = row[DeviceTable.lastSyncAt], + isOnline = row[DeviceTable.isOnline], + isSynchronized = row[DeviceTable.isSynchronized], createdAt = row[DeviceTable.createdAt], updatedAt = row[DeviceTable.updatedAt] ) diff --git a/docs/99_Journal/2026-04-16_Explicit-Device-Enrollment.md b/docs/99_Journal/2026-04-16_Explicit-Device-Enrollment.md new file mode 100644 index 00000000..1a94ceb1 --- /dev/null +++ b/docs/99_Journal/2026-04-16_Explicit-Device-Enrollment.md @@ -0,0 +1,39 @@ +--- +type: Journal +status: ACTIVE +owner: Curator +created: 2026-04-16 +--- + +# Journal — 16. April 2026 (Explicit Device Enrollment) + +## 🎯 Ziel & Entscheidung + +Implementierung des **"Explicit Device Enrollment"**-Konzepts im Onboarding-Prozess. +Ein Master-Gerät definiert nun vorab, welche Clients (Name & Rolle) im lokalen Netzwerk erwartet werden. +Dies erhöht die Sicherheit und automatisiert die Feature-Freischaltung auf den Clients. + +## 🏗️ Architektur-Änderungen + +- **Backend (Identity-Service):** + - `DeviceRole` wurde um fachspezifische Rollen erweitert (`RICHTER`, `ZEITNEHMER`, `STALLMEISTER`, `ANZEIGE`, + `PARCOURS_CHEF`). + - `DeviceTable` (Exposed) und `Device`-Modell enthalten nun `expectedName`, `isOnline` und `isSynchronized`. +- **Frontend (Desktop-App):** + - `OnboardingSettings` speichert nun eine Liste von `ExpectedClient`. + - `OnboardingScreen` (v2) bietet Master-Geräten eine Tabelle zum Verwalten dieser Liste. + - `OnboardingValidator` stellt sicher, dass alle erwarteten Geräte einen Namen haben, bevor die Konfiguration + gespeichert wird. + +## 🧪 Verifikation + +- Erweiterte Unit-Tests in `OnboardingValidatorTest` decken die Validierung der Client-Liste ab (24/24 Tests grün). +- UI-Komponenten (Dropdown für Rollen, Add/Delete-Actions) wurden in `Screens.kt` integriert. + +## 🔗 Relevante Dateien + +- `backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/Device.kt` +- +`backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/DeviceTable.kt` +- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingSettings.kt` +- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt` diff --git a/frontend/shells/meldestelle-desktop/settings.json b/frontend/shells/meldestelle-desktop/settings.json index 0d2bf0d0..681b4189 100644 --- a/frontend/shells/meldestelle-desktop/settings.json +++ b/frontend/shells/meldestelle-desktop/settings.json @@ -1,6 +1,12 @@ { "geraetName": "Meldestelle", "sharedKey": "Meldestelle", - "backupPath": "/mocode/Meldestelle/docs/temp", - "networkRole": "MASTER" + "backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp", + "networkRole": "MASTER", + "expectedClients": [ + { + "name": "Richter-Turm", + "role": "RICHTER" + } + ] } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingSettings.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingSettings.kt index 1e54922a..4047be03 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingSettings.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingSettings.kt @@ -5,15 +5,29 @@ import kotlinx.serialization.Serializable @Serializable enum class NetworkRole { MASTER, - CLIENT + CLIENT, + RICHTER, + ZEITNEHMER, + STALLMEISTER, + ANZEIGE, + PARCOURS_CHEF } +@Serializable +data class ExpectedClient( + val name: String, + val role: NetworkRole, + val isOnline: Boolean = false, + val isSynchronized: Boolean = true +) + @Serializable data class OnboardingSettings( val geraetName: String = "", val sharedKey: String = "", val backupPath: String = "", val networkRole: NetworkRole = NetworkRole.CLIENT, + val expectedClients: List = emptyList(), val syncInterval: Int = 30, // in Minuten val defaultPrinter: String = "" ) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidator.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidator.kt index 9cec6154..59c27196 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidator.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidator.kt @@ -37,9 +37,19 @@ object OnboardingValidator { * Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und * der „Weiter"-Button aktiviert werden darf. */ - fun canContinue(settings: OnboardingSettings): Boolean = - isNameValid(settings.geraetName) && + fun canContinue(settings: OnboardingSettings): Boolean { + val basicValid = isNameValid(settings.geraetName) && isKeyValid(settings.sharedKey) && isBackupPathValid(settings.backupPath) && isSyncIntervalValid(settings.syncInterval) + + if (!basicValid) return false + + // Falls Master, müssen alle erwarteten Clients einen Namen haben + if (settings.networkRole == NetworkRole.MASTER) { + return settings.expectedClients.all { it.name.trim().isNotEmpty() } + } + + return true + } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt index ddab87e0..cc52b7a0 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import at.mocode.desktop.screens.onboarding.ExpectedClient import at.mocode.desktop.screens.onboarding.NetworkRole import at.mocode.desktop.screens.onboarding.OnboardingSettings import at.mocode.desktop.screens.onboarding.OnboardingValidator @@ -142,6 +143,77 @@ fun OnboardingScreen( ) } + if (settings.networkRole == NetworkRole.MASTER) { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Text("📋 Erwartete Geräte (Clients)", style = MaterialTheme.typography.titleSmall) + Text( + "Definiere hier, welche Geräte sich in diesem Netzwerk anmelden dürfen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + settings.expectedClients.forEachIndexed { index, client -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MsTextField( + value = client.name, + onValueChange = { newName -> + val newList = settings.expectedClients.toMutableList() + newList[index] = client.copy(name = newName) + onSettingsChange(settings.copy(expectedClients = newList)) + }, + label = "Name", + modifier = Modifier.weight(1f) + ) + + var expanded by remember { mutableStateOf(false) } + Box { + OutlinedButton(onClick = { expanded = true }) { + Text(client.role.name) + Icon(Icons.Default.ArrowDropDown, null) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + NetworkRole.entries.filter { it != NetworkRole.MASTER }.forEach { role -> + DropdownMenuItem( + text = { Text(role.name) }, + onClick = { + val newList = settings.expectedClients.toMutableList() + newList[index] = client.copy(role = role) + onSettingsChange(settings.copy(expectedClients = newList)) + expanded = false + } + ) + } + } + } + + IconButton(onClick = { + val newList = settings.expectedClients.toMutableList() + newList.removeAt(index) + onSettingsChange(settings.copy(expectedClients = newList)) + }) { + Icon(Icons.Default.Delete, contentDescription = "Entfernen", tint = MaterialTheme.colorScheme.error) + } + } + } + + TextButton( + onClick = { + val newList = settings.expectedClients.toMutableList() + newList.add(ExpectedClient("Neues Gerät", NetworkRole.CLIENT)) + onSettingsChange(settings.copy(expectedClients = newList)) + }, + modifier = Modifier.padding(top = 8.dp) + ) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(8.dp)) + Text("Gerät hinzufügen") + } + } + var showPrinterDialog by remember { mutableStateOf(false) } val availablePrinters = remember { PrintServiceLookup.lookupPrintServices(null, null).map { it.name } diff --git a/frontend/shells/meldestelle-desktop/src/jvmTest/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidatorTest.kt b/frontend/shells/meldestelle-desktop/src/jvmTest/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidatorTest.kt index 705362c0..e3f2a196 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmTest/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidatorTest.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmTest/kotlin/at/mocode/desktop/screens/onboarding/OnboardingValidatorTest.kt @@ -136,7 +136,7 @@ class OnboardingValidatorTest { @Test fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() { - // Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern + // Simuliert schneller Doppelklick: canContinue darf sich nicht ändern val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp") val first = OnboardingValidator.canContinue(settings) val second = OnboardingValidator.canContinue(settings) @@ -186,4 +186,36 @@ class OnboardingValidatorTest { "Nach Abbrechen darf der Weiter-Button nicht aktiviert sein" ) } + + // ─── Explicit Device Enrollment (Master) ─────────────────────────────────── + + @Test + fun `Master canContinue false wenn ein erwarteter Client keinen Namen hat`() { + val masterSettings = OnboardingSettings( + geraetName = "Master-PC", + sharedKey = "Neumarkt2026", + backupPath = "/tmp", + networkRole = NetworkRole.MASTER, + expectedClients = listOf( + ExpectedClient("Richter 1", NetworkRole.RICHTER), + ExpectedClient("", NetworkRole.ZEITNEHMER) // Ungültig: Name leer + ) + ) + assertFalse(OnboardingValidator.canContinue(masterSettings)) + } + + @Test + fun `Master canContinue true wenn alle erwarteten Clients Namen haben`() { + val masterSettings = OnboardingSettings( + geraetName = "Master-PC", + sharedKey = "Neumarkt2026", + backupPath = "/tmp", + networkRole = NetworkRole.MASTER, + expectedClients = listOf( + ExpectedClient("Richter 1", NetworkRole.RICHTER), + ExpectedClient("Zeitnehmer 1", NetworkRole.ZEITNEHMER) + ) + ) + assertTrue(OnboardingValidator.canContinue(masterSettings)) + } }