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:
+10
-1
@@ -10,13 +10,22 @@ import kotlin.time.Instant
|
|||||||
data class Device(
|
data class Device(
|
||||||
val id: UUID = UUID.randomUUID(),
|
val id: UUID = UUID.randomUUID(),
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val expectedName: String? = null, // Falls vom Master vor-registriert
|
||||||
val securityKeyHash: String, // Gehasht für Sicherheit
|
val securityKeyHash: String, // Gehasht für Sicherheit
|
||||||
val role: DeviceRole = DeviceRole.CLIENT,
|
val role: DeviceRole = DeviceRole.CLIENT,
|
||||||
val lastSyncAt: Instant? = null,
|
val lastSyncAt: Instant? = null,
|
||||||
|
val isOnline: Boolean = false,
|
||||||
|
val isSynchronized: Boolean = true,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
val updatedAt: Instant = createdAt
|
val updatedAt: Instant = createdAt
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class DeviceRole {
|
enum class DeviceRole {
|
||||||
MASTER, CLIENT
|
MASTER,
|
||||||
|
CLIENT,
|
||||||
|
RICHTER,
|
||||||
|
ZEITNEHMER,
|
||||||
|
STALLMEISTER,
|
||||||
|
ANZEIGE,
|
||||||
|
PARCOURS_CHEF
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -13,10 +13,13 @@ object DeviceTable : Table("identity_devices") {
|
|||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
val name = varchar("name", 100).uniqueIndex()
|
val name = varchar("name", 100).uniqueIndex()
|
||||||
|
val expectedName = varchar("expected_name", 100).nullable()
|
||||||
val securityKeyHash = varchar("security_key_hash", 255)
|
val securityKeyHash = varchar("security_key_hash", 255)
|
||||||
val role = enumerationByName("role", 20, DeviceRole::class)
|
val role = enumerationByName("role", 20, DeviceRole::class)
|
||||||
|
|
||||||
val lastSyncAt = timestamp("last_sync_at").nullable()
|
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 createdAt = timestamp("created_at")
|
||||||
val updatedAt = timestamp("updated_at")
|
val updatedAt = timestamp("updated_at")
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -33,18 +33,24 @@ class ExposedDeviceRepository : DeviceRepository {
|
|||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
DeviceTable.update({ DeviceTable.id eq device.id }) {
|
DeviceTable.update({ DeviceTable.id eq device.id }) {
|
||||||
it[name] = device.name
|
it[name] = device.name
|
||||||
|
it[expectedName] = device.expectedName
|
||||||
it[securityKeyHash] = device.securityKeyHash
|
it[securityKeyHash] = device.securityKeyHash
|
||||||
it[role] = device.role
|
it[role] = device.role
|
||||||
it[lastSyncAt] = device.lastSyncAt
|
it[lastSyncAt] = device.lastSyncAt
|
||||||
|
it[isOnline] = device.isOnline
|
||||||
|
it[isSynchronized] = device.isSynchronized
|
||||||
it[updatedAt] = now
|
it[updatedAt] = now
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DeviceTable.insert {
|
DeviceTable.insert {
|
||||||
it[id] = device.id
|
it[id] = device.id
|
||||||
it[name] = device.name
|
it[name] = device.name
|
||||||
|
it[expectedName] = device.expectedName
|
||||||
it[securityKeyHash] = device.securityKeyHash
|
it[securityKeyHash] = device.securityKeyHash
|
||||||
it[role] = device.role
|
it[role] = device.role
|
||||||
it[lastSyncAt] = device.lastSyncAt
|
it[lastSyncAt] = device.lastSyncAt
|
||||||
|
it[isOnline] = device.isOnline
|
||||||
|
it[isSynchronized] = device.isSynchronized
|
||||||
it[createdAt] = device.createdAt
|
it[createdAt] = device.createdAt
|
||||||
it[updatedAt] = now
|
it[updatedAt] = now
|
||||||
}
|
}
|
||||||
@@ -62,9 +68,12 @@ class ExposedDeviceRepository : DeviceRepository {
|
|||||||
private fun rowToDevice(row: ResultRow): Device = Device(
|
private fun rowToDevice(row: ResultRow): Device = Device(
|
||||||
id = row[DeviceTable.id],
|
id = row[DeviceTable.id],
|
||||||
name = row[DeviceTable.name],
|
name = row[DeviceTable.name],
|
||||||
|
expectedName = row[DeviceTable.expectedName],
|
||||||
securityKeyHash = row[DeviceTable.securityKeyHash],
|
securityKeyHash = row[DeviceTable.securityKeyHash],
|
||||||
role = row[DeviceTable.role],
|
role = row[DeviceTable.role],
|
||||||
lastSyncAt = row[DeviceTable.lastSyncAt],
|
lastSyncAt = row[DeviceTable.lastSyncAt],
|
||||||
|
isOnline = row[DeviceTable.isOnline],
|
||||||
|
isSynchronized = row[DeviceTable.isSynchronized],
|
||||||
createdAt = row[DeviceTable.createdAt],
|
createdAt = row[DeviceTable.createdAt],
|
||||||
updatedAt = row[DeviceTable.updatedAt]
|
updatedAt = row[DeviceTable.updatedAt]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"geraetName": "Meldestelle",
|
"geraetName": "Meldestelle",
|
||||||
"sharedKey": "Meldestelle",
|
"sharedKey": "Meldestelle",
|
||||||
"backupPath": "/mocode/Meldestelle/docs/temp",
|
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
|
||||||
"networkRole": "MASTER"
|
"networkRole": "MASTER",
|
||||||
|
"expectedClients": [
|
||||||
|
{
|
||||||
|
"name": "Richter-Turm",
|
||||||
|
"role": "RICHTER"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -5,15 +5,29 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
enum class NetworkRole {
|
enum class NetworkRole {
|
||||||
MASTER,
|
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
|
@Serializable
|
||||||
data class OnboardingSettings(
|
data class OnboardingSettings(
|
||||||
val geraetName: String = "",
|
val geraetName: String = "",
|
||||||
val sharedKey: String = "",
|
val sharedKey: String = "",
|
||||||
val backupPath: String = "",
|
val backupPath: String = "",
|
||||||
val networkRole: NetworkRole = NetworkRole.CLIENT,
|
val networkRole: NetworkRole = NetworkRole.CLIENT,
|
||||||
|
val expectedClients: List<ExpectedClient> = emptyList(),
|
||||||
val syncInterval: Int = 30, // in Minuten
|
val syncInterval: Int = 30, // in Minuten
|
||||||
val defaultPrinter: String = ""
|
val defaultPrinter: String = ""
|
||||||
)
|
)
|
||||||
|
|||||||
+12
-2
@@ -37,9 +37,19 @@ object OnboardingValidator {
|
|||||||
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
|
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
|
||||||
* der „Weiter"-Button aktiviert werden darf.
|
* der „Weiter"-Button aktiviert werden darf.
|
||||||
*/
|
*/
|
||||||
fun canContinue(settings: OnboardingSettings): Boolean =
|
fun canContinue(settings: OnboardingSettings): Boolean {
|
||||||
isNameValid(settings.geraetName) &&
|
val basicValid = isNameValid(settings.geraetName) &&
|
||||||
isKeyValid(settings.sharedKey) &&
|
isKeyValid(settings.sharedKey) &&
|
||||||
isBackupPathValid(settings.backupPath) &&
|
isBackupPathValid(settings.backupPath) &&
|
||||||
isSyncIntervalValid(settings.syncInterval)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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.NetworkRole
|
||||||
import at.mocode.desktop.screens.onboarding.OnboardingSettings
|
import at.mocode.desktop.screens.onboarding.OnboardingSettings
|
||||||
import at.mocode.desktop.screens.onboarding.OnboardingValidator
|
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) }
|
var showPrinterDialog by remember { mutableStateOf(false) }
|
||||||
val availablePrinters = remember {
|
val availablePrinters = remember {
|
||||||
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
|
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
|
||||||
|
|||||||
+33
-1
@@ -136,7 +136,7 @@ class OnboardingValidatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
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 settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
|
||||||
val first = OnboardingValidator.canContinue(settings)
|
val first = OnboardingValidator.canContinue(settings)
|
||||||
val second = OnboardingValidator.canContinue(settings)
|
val second = OnboardingValidator.canContinue(settings)
|
||||||
@@ -186,4 +186,36 @@ class OnboardingValidatorTest {
|
|||||||
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
|
"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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user