feat(desktop-onboarding): neue Onboarding-UI implementiert, Backup- und Rollenmanagement hinzugefügt
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 3m10s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m37s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled

- Einbindung eines komplett überarbeiteten Onboarding-Screens mit validierten Eingaben für Gerätename, Sicherheitsschlüssel und Backup-Pfad.
- `SettingsManager` eingeführt zur Speicherung der Onboarding-Daten in `settings.json`.
- Navigation verbessert: Onboarding-Workflow startet, wenn Konfiguration fehlt; neues "Setup"-Icon in der Navigationsleiste hinzugefügt.
- Backend: Geräte-API und `DeviceSecurityFilter` für Authentifizierung per Sicherheitsschlüssel implementiert.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-15 15:48:55 +02:00
parent a5f5e7a24b
commit a6fcb81594
23 changed files with 900 additions and 275 deletions
@@ -0,0 +1,21 @@
package at.mocode.identity.domain.model
import kotlinx.datetime.Instant
import java.util.*
/**
* Repräsentiert eine registrierte Desktop-Instanz ("Gerät").
* Die Identität wird während des Onboarding-Prozesses festgelegt.
*/
data class Device(
val id: UUID = UUID.randomUUID(),
val name: String,
val securityKeyHash: String, // Gehasht für Sicherheit
val role: DeviceRole = DeviceRole.CLIENT,
val lastSyncAt: Instant? = null,
val createdAt: Instant
)
enum class DeviceRole {
MASTER, CLIENT
}
@@ -0,0 +1,12 @@
package at.mocode.identity.domain.repository
import at.mocode.identity.domain.model.Device
import kotlinx.datetime.Instant
import java.util.*
interface DeviceRepository {
suspend fun findById(id: UUID): Device?
suspend fun findByName(name: String): Device?
suspend fun save(device: Device): Device
suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean
}
@@ -0,0 +1,39 @@
package at.mocode.identity.domain.service
import at.mocode.identity.domain.model.Device
import at.mocode.identity.domain.model.DeviceRole
import at.mocode.identity.domain.repository.DeviceRepository
import java.util.*
import kotlin.time.Clock
class DeviceService(
private val deviceRepository: DeviceRepository
) {
suspend fun registerDevice(name: String, securityKeyHash: String, role: DeviceRole): Device {
val existing = deviceRepository.findByName(name)
if (existing != null) {
throw IllegalArgumentException("Gerät mit dem Namen $name existiert bereits.")
}
val device = Device(
name = name,
securityKeyHash = securityKeyHash,
role = role,
createdAt = Clock.System.now()
)
return deviceRepository.save(device)
}
suspend fun validateDeviceKey(name: String, securityKeyHash: String): Boolean {
val device = deviceRepository.findByName(name) ?: return false
return device.securityKeyHash == securityKeyHash
}
suspend fun getDeviceByName(name: String): Device? {
return deviceRepository.findByName(name)
}
suspend fun updateSyncTime(deviceId: UUID): Boolean {
return deviceRepository.updateLastSyncAt(deviceId, Clock.System.now())
}
}
@@ -0,0 +1,22 @@
package at.mocode.identity.infrastructure.persistence
import at.mocode.identity.domain.model.DeviceRole
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed Table definition für registrierte Desktop-Geräte.
*/
object DeviceTable : Table("identity_devices") {
val id = javaUUID("id").autoGenerate()
override val primaryKey = PrimaryKey(id)
val name = varchar("name", 100).uniqueIndex()
val securityKeyHash = varchar("security_key_hash", 255)
val role = enumerationByName("role", 20, DeviceRole::class)
val lastSyncAt = timestamp("last_sync_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
}
@@ -0,0 +1,72 @@
package at.mocode.identity.infrastructure.persistence
import at.mocode.identity.domain.model.Device
import at.mocode.identity.domain.repository.DeviceRepository
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.util.*
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.time.toJavaInstant
class ExposedDeviceRepository : DeviceRepository {
override suspend fun findById(id: UUID): Device? = transaction {
DeviceTable.selectAll().where { DeviceTable.id eq id }
.map { rowToDevice(it) }
.singleOrNull()
}
override suspend fun findByName(name: String): Device? = transaction {
DeviceTable.selectAll().where { DeviceTable.name eq name }
.map { rowToDevice(it) }
.singleOrNull()
}
override suspend fun save(device: Device): Device = transaction {
val now = Clock.System.now()
val existing = DeviceTable.selectAll().where { DeviceTable.id eq device.id }.singleOrNull()
if (existing != null) {
DeviceTable.update({ DeviceTable.id eq device.id }) {
it[name] = device.name
it[securityKeyHash] = device.securityKeyHash
it[role] = device.role
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
it[updatedAt] = now.toJavaInstant()
}
} else {
DeviceTable.insert {
it[id] = device.id
it[name] = device.name
it[securityKeyHash] = device.securityKeyHash
it[role] = device.role
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
it[createdAt] = now.toJavaInstant()
it[updatedAt] = now.toJavaInstant()
}
}
device
}
override suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean = transaction {
val javaInstant = at.toJavaInstant()
DeviceTable.update({ DeviceTable.id eq id }) {
it[lastSyncAt] = javaInstant
it[updatedAt] = javaInstant
} > 0
}
private fun rowToDevice(row: ResultRow): Device = Device(
id = row[DeviceTable.id],
name = row[DeviceTable.name],
securityKeyHash = row[DeviceTable.securityKeyHash],
role = row[DeviceTable.role],
lastSyncAt = row[DeviceTable.lastSyncAt]?.let { Instant.fromEpochMilliseconds(it.toEpochMilli()) },
createdAt = Instant.fromEpochMilliseconds(row[DeviceTable.createdAt].toEpochMilli())
)
}
@@ -1,7 +1,10 @@
package at.mocode.identity.service.config
import at.mocode.identity.domain.repository.DeviceRepository
import at.mocode.identity.domain.repository.ProfileRepository
import at.mocode.identity.domain.service.DeviceService
import at.mocode.identity.domain.service.ProfileService
import at.mocode.identity.infrastructure.persistence.ExposedDeviceRepository
import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@@ -15,4 +18,11 @@ class IdentityConfig {
@Bean
fun profileService(profileRepository: ProfileRepository): ProfileService =
ProfileService(profileRepository)
@Bean
fun deviceRepository(): DeviceRepository = ExposedDeviceRepository()
@Bean
fun deviceService(deviceRepository: DeviceRepository): DeviceService =
DeviceService(deviceRepository)
}
@@ -0,0 +1,33 @@
package at.mocode.identity.service.web
import at.mocode.identity.domain.model.Device
import at.mocode.identity.domain.model.DeviceRole
import at.mocode.identity.domain.service.DeviceService
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/v1/devices")
class DeviceController(
private val deviceService: DeviceService
) {
@PostMapping("/register")
suspend fun registerDevice(@RequestBody request: DeviceRegisterRequest): Device {
return deviceService.registerDevice(
name = request.name,
securityKeyHash = request.securityKeyHash,
role = request.role
)
}
@GetMapping("/{name}")
suspend fun getDevice(@PathVariable name: String): Device? {
return deviceService.getDeviceByName(name)
}
}
data class DeviceRegisterRequest(
val name: String,
val securityKeyHash: String,
val role: DeviceRole
)
@@ -79,7 +79,7 @@ class MailController(
val dynamicFrom = try {
val (user, domain) = baseMailAddress.split("@")
"$user+${request.turnierNr}@$domain"
} catch (e: Exception) {
} catch (_: Exception) {
baseMailAddress
}