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:
2026-04-16 10:24:49 +02:00
parent f98a9075ae
commit 82a4a13505
9 changed files with 201 additions and 7 deletions
@@ -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))
}
}