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:
parent
f98a9075ae
commit
82a4a13505
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
39
docs/99_Journal/2026-04-16_Explicit-Device-Enrollment.md
Normal file
39
docs/99_Journal/2026-04-16_Explicit-Device-Enrollment.md
Normal 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`
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user