feat(onboarding): Explicit Device Enrollment für Master-Geräte hinzugefügt

- Master-Geräte können erwartete Clients inkl. Name & Rolle definieren.
- Neue Rollen (`RICHTER`, `ZEITNEHMER` etc.) integriert.
- Backend- und Frontend-Validierung erweitert, UI-Komponente für Client-Verwaltung.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-04-16 10:24:49 +02:00
parent f98a9075ae
commit 82a4a13505
9 changed files with 201 additions and 7 deletions

View File

@ -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
}

View File

@ -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")
}

View File

@ -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]
)

View File

@ -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`

View File

@ -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"
}
]
}

View File

@ -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<ExpectedClient> = emptyList(),
val syncInterval: Int = 30, // in Minuten
val defaultPrinter: String = ""
)

View File

@ -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
}
}

View File

@ -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 }

View File

@ -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))
}
}