diff --git a/build-number.properties b/build-number.properties
index e338dba..f161ec5 100644
--- a/build-number.properties
+++ b/build-number.properties
@@ -15,4 +15,4 @@
# You should have received a copy of the GNU General Public License along
# with Fhraise. If not, see .
#
-buildNumber=45
+buildNumber=46
diff --git a/build.gradle.kts b/build.gradle.kts
index 8ca4486..f76416b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -396,6 +396,13 @@ tasks.register("runWebApp") {
dependsOn("compose-app:wasmJsBrowserDevelopmentRun")
}
+tasks.register("runServer") {
+ group = "project build"
+ description = "Run the server"
+
+ dependsOn("server:run")
+}
+
tasks.register("installReleaseAndroidApp") {
group = "project build"
description = "Install the Android release APK"
diff --git a/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/data/components/root/SignInComponent.kt b/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/data/components/root/SignInComponent.kt
index 25f1cf8..d56d0cf 100644
--- a/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/data/components/root/SignInComponent.kt
+++ b/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/data/components/root/SignInComponent.kt
@@ -22,6 +22,8 @@ import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.text.KeyboardActionScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -53,7 +55,7 @@ import xyz.xfqlittlefan.fhraise.routes.Api.Auth.Type.Request.VerificationType
import kotlin.js.JsName
internal typealias OnRequest = suspend (client: HttpClient, credential: String) -> Boolean
-internal typealias OnVerify = suspend (client: HttpClient, verification: String) -> Boolean
+internal typealias OnVerify = suspend (client: HttpClient, verification: String) -> JwtTokenPair?
interface SignInComponent : AppComponentContext {
var step: Step
@@ -150,13 +152,12 @@ interface SignInComponent : AppComponentContext {
null
}?.let {
verifyingToken = null
- enter(it.tokenPair)
- true
- } ?: false
+ it.tokenPair
+ }
}
- VerificationType.QrCode -> { _, _ -> false }
- VerificationType.Face -> { _, _ -> false }
+ VerificationType.QrCode -> { _, _ -> null }
+ VerificationType.Face -> { _, _ -> null }
}
val CredentialType.use: () -> Unit
@@ -204,7 +205,7 @@ interface SignInComponent : AppComponentContext {
fun onMicrosoftSignIn()
- fun enter(tokenPair: JwtTokenPair? = null)
+ fun enter()
val enterAction: KeyboardActionScope.() -> Unit
get() = {
@@ -234,7 +235,7 @@ interface SignInComponent : AppComponentContext {
}
class AppSignInComponent(
- componentContext: AppComponentContext, private val onEnter: () -> Unit
+ componentContext: AppComponentContext, onEnter: (JwtTokenPair?) -> Unit
) : SignInComponent, AppComponentContext by componentContext {
override var step by mutableStateOf(EnteringCredential)
override var credentialType by mutableStateOf(CredentialType.Username)
@@ -286,20 +287,39 @@ class AppSignInComponent(
override fun onMicrosoftSignIn() = onOAuthSignIn(Api.OAuth.Provider.Microsoft)
- override fun enter(tokenPair: JwtTokenPair?) {
+ private val onEnter: (JwtTokenPair?) -> Unit = onEnter@{ tokenPair ->
+ onEnter(tokenPair)
+ if (tokenPair != null) return@onEnter
+ componentScope.launch {
+ snackbarHostState.showSnackbar(
+ message = "登录账号可享受完整服务",
+ actionLabel = "返回登录",
+ withDismissAction = true,
+ duration = SnackbarDuration.Short
+ ).let {
+ if (it == SnackbarResult.ActionPerformed) Unit // TODO
+ }
+ }
+ }
+
+ override fun enter() {
if (!credentialValid) return
verificationType?.let {
componentScope.launch {
- if (it.onVerify(client, verification)) {
+ it.onVerify(client, verification)?.let {
snackbarHostState.showSnackbar(message = "验证成功", withDismissAction = true)
- onEnter()
- } else {
- snackbarHostState.showSnackbar(message = "验证失败", withDismissAction = true)
+ onEnter(it)
+ } ?: snackbarHostState.showSnackbar(
+ message = "验证失败",
+ actionLabel = "仅浏览",
+ withDismissAction = true,
+ duration = SnackbarDuration.Short
+ ).let {
+ if (it == SnackbarResult.ActionPerformed) onEnter(null)
}
}
} ?: run {
- // TODO: 询问是否验证
- onEnter()
+ onEnter(null)
}
}
@@ -318,7 +338,12 @@ class AppSignInComponent(
if (requestVerification()) {
step = Verification
} else {
- snackbarHostState.showSnackbar(message = "请求验证失败", withDismissAction = true)
+ snackbarHostState.showSnackbar(
+ message = "请求验证失败",
+ actionLabel = "仅浏览",
+ withDismissAction = true,
+ duration = SnackbarDuration.Short
+ )
}
}
}
diff --git a/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/ui/pages/root/SignIn.kt b/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/ui/pages/root/SignIn.kt
index 395ad0f..62ec4d7 100644
--- a/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/ui/pages/root/SignIn.kt
+++ b/compose-app/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/ui/pages/root/SignIn.kt
@@ -180,6 +180,12 @@ fun SignInComponent.SignIn() {
}
Spacer(modifier = Modifier.height(8.dp))
Credential()
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "新用户将自动注册,未添加验证方式的用户将在 7 天后被删除",
+ modifier = Modifier.alpha(0.6f),
+ style = MaterialTheme.typography.labelMedium
+ )
Spacer(modifier = Modifier.height(32.dp))
Box(
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp),
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6443dc1..c024ec6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,6 +2,7 @@
kotlin = "1.9.22"
kotlinx-atomicfu = "0.23.2"
kotlinx-coroutines = "1.8.0"
+kotlinx-datetime = "0.5.0"
agp = "8.2.2"
androidx-core-splashscreen = "1.0.1"
androidx-window = "1.2.0"
@@ -29,6 +30,7 @@ kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu-gradle-plugin", ve
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" }
androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
@@ -54,6 +56,7 @@ ktor-server-resources = { module = "io.ktor:ktor-server-resources", version.ref
ktor-server-rate-limit = { module = "io.ktor:ktor-server-rate-limit", version.ref = "ktor" }
ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" }
+ktor-server-double-receive = { module = "io.ktor:ktor-server-double-receive", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
ktor-server-html-builder = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor" }
diff --git a/server/build.gradle.kts b/server/build.gradle.kts
index 09f5b9c..5af2368 100644
--- a/server/build.gradle.kts
+++ b/server/build.gradle.kts
@@ -54,6 +54,7 @@ dependencies {
implementation(libs.ktor.server.rate.limit)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.auth.jwt)
+ implementation(libs.ktor.server.double.receive)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.server.websockets)
implementation(libs.ktor.server.html.builder)
diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/Application.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/Application.kt
index b21cf8e..cc59701 100644
--- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/Application.kt
+++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/Application.kt
@@ -27,6 +27,7 @@ import io.ktor.server.config.*
import io.ktor.server.engine.*
import io.ktor.server.plugins.callid.*
import io.ktor.server.plugins.contentnegotiation.*
+import io.ktor.server.plugins.doublereceive.*
import io.ktor.server.plugins.ratelimit.*
import io.ktor.server.resources.*
import io.ktor.server.routing.*
@@ -37,6 +38,7 @@ import xyz.xfqlittlefan.fhraise.api.apiAuth
import xyz.xfqlittlefan.fhraise.api.apiOAuth
import xyz.xfqlittlefan.fhraise.api.appAuth
import xyz.xfqlittlefan.fhraise.api.registerAppCodeVerification
+import xyz.xfqlittlefan.fhraise.auth.cleanupUnverifiedUsersPreDay
import xyz.xfqlittlefan.fhraise.models.cleanupVerificationCodes
import xyz.xfqlittlefan.fhraise.proxy.proxyKeycloak
@@ -58,12 +60,15 @@ fun Application.module() {
appAuth()
}
+ install(DoubleReceive)
+
install(ContentNegotiation) { cbor() }
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Cbor)
}
+ cleanupUnverifiedUsersPreDay()
cleanupVerificationCodes()
routing {
diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt
index 21bc572..41665d9 100644
--- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt
+++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/api/Auth.kt
@@ -209,6 +209,19 @@ private suspend fun RoutingCall.respondCodeVerificationResult(
if (verificationValid) {
getOrCreateUser(request.parent.credentialType provide token.credential).fold(
onSuccess = { user ->
+ var userNeedsUpdate = false
+
+ if (user.enabled != true) {
+ user.enabled = true
+ userNeedsUpdate = true
+ }
+ if (request.parent.credentialType == Email && user.emailVerified != true) {
+ user.emailVerified = true
+ userNeedsUpdate = true
+ }
+
+ if (userNeedsUpdate) user.update()
+
exchangeToken(user.id!!)?.let {
respond(Api.Auth.Type.Verify.ResponseBody.Success(it))
} ?: respond(Api.Auth.Type.Verify.ResponseBody.Failure)
@@ -228,6 +241,10 @@ private suspend fun RoutingCall.respondPasswordVerificationResult(
return
}
+ if (user.getCredentials()?.any { it.type == CredentialRepresentation.CredentialType.Password } != true) {
+ user.resetPassword { value = body.verification.value }.onSuccess { user.update { enabled = true } }
+ }
+
keycloakClient.getTokensByPassword(authClientId, authClientSecret, user.username!!, body.verification)?.let {
respond(Api.Auth.Type.Verify.ResponseBody.Success(JwtTokenPair(it.accessToken, it.refreshToken)))
} ?: respond(Api.Auth.Type.Verify.ResponseBody.Failure)
diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt
index 5706589..46275ff 100644
--- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt
+++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/Keycloak.kt
@@ -28,15 +28,24 @@ import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
+import io.ktor.server.application.*
import io.ktor.server.util.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.Json
import xyz.xfqlittlefan.fhraise.appSecret
+import xyz.xfqlittlefan.fhraise.models.CredentialRepresentation
import xyz.xfqlittlefan.fhraise.models.UserQuery
import xyz.xfqlittlefan.fhraise.models.UserRepresentation
import xyz.xfqlittlefan.fhraise.proxy.keycloakHost
import xyz.xfqlittlefan.fhraise.proxy.keycloakPort
import xyz.xfqlittlefan.fhraise.proxy.keycloakScheme
import xyz.xfqlittlefan.fhraise.routes.Api
+import kotlin.time.Duration.Companion.days
private val authClient = HttpClient {
install(ContentNegotiation) {
@@ -78,13 +87,16 @@ private var currentTokens: BearerTokens? = null
private const val phoneNumberAttribute = "phoneNumber"
-private suspend fun loadTokens(): BearerTokens? = currentTokens ?: authClient.getTokensByPassword(
+private suspend fun loadTokens() =
+ currentTokens ?: getTokens()?.let { BearerTokens(it.accessToken, it.refreshToken) }?.also { currentTokens = it }
+
+private suspend fun getTokens() = authClient.getTokensByPassword(
adminClientId, adminClientSecret, adminUsername, Api.Auth.Type.Verify.RequestBody.Verification(adminPassword)
-)?.let { BearerTokens(it.accessToken, it.refreshToken) }?.also { currentTokens = it }
+)
-private suspend fun RefreshTokensParams.refreshTokens(): BearerTokens? =
+private suspend fun RefreshTokensParams.refreshTokens() =
(oldTokens?.refreshToken ?: currentTokens?.refreshToken)?.let {
- authClient.refreshTokens(adminClientId, adminClientSecret, it)
+ authClient.refreshTokens(adminClientId, adminClientSecret, it) ?: getTokens()
}?.let { BearerTokens(it.accessToken, it.refreshToken) }?.also { currentTokens = it }
suspend fun exchangeToken(userId: String) = currentTokens?.let {
@@ -122,13 +134,23 @@ suspend fun getUser(block: UserQuery.() -> Unit) = adminClient.get(adminUrl {
}
}).let {
when {
- it.status.isSuccess() -> it.body>().first()
+ it.status.isSuccess() -> it.body>().firstOrNull()
+ else -> null
+ }
+}?.let {
+ adminClient.get(adminUrl {
+ appendPathSegments("users", it.id!!)
+ })
+}?.let {
+ when {
+ it.status.isSuccess() -> it.body()
else -> null
}
}
private suspend fun createUser(query: UserQuery) = adminClient.post {
url(adminUrl { appendPathSegments("users") })
+ contentType(ContentType.Application.Json)
setBody(UserRepresentation().apply {
username = query.generatedUsername
email = query.email
@@ -136,6 +158,67 @@ private suspend fun createUser(query: UserQuery) = adminClient.post {
})
}.let { if (it.status.isSuccess()) Result.success(Unit) else Result.failure(Exception(it.bodyAsText())) }
+suspend fun UserRepresentation.getCredentials() = adminClient.get(adminUrl {
+ appendPathSegments("users", id!!, "credentials")
+}).let {
+ when {
+ it.status.isSuccess() -> it.body>()
+ else -> null
+ }
+}
+
+suspend fun UserRepresentation.update(block: UserRepresentation.() -> Unit = {}) =
+ this@update.apply(block).let { newUser ->
+ adminClient.put {
+ url(adminUrl { appendPathSegments("users", id!!) })
+ contentType(ContentType.Application.Json)
+ setBody(newUser)
+ }.let { if (it.status.isSuccess()) Result.success(newUser) else Result.failure(Exception(it.bodyAsText())) }
+ }
+
+suspend fun UserRepresentation.resetPassword(block: CredentialRepresentation.() -> Unit) = adminClient.put {
+ url(adminUrl { appendPathSegments("users", id!!, "reset-password") })
+ contentType(ContentType.Application.Json)
+ setBody(CredentialRepresentation(type = CredentialRepresentation.CredentialType.Password).apply(block))
+}.let { if (it.status.isSuccess()) Result.success(Unit) else Result.failure(Exception(it.bodyAsText())) }
+
+private suspend fun UserRepresentation.delete() = adminClient.delete {
+ url(adminUrl { appendPathSegments("users", id!!) })
+}.let { if (it.status.isSuccess()) Result.success(Unit) else Result.failure(Exception(it.bodyAsText())) }
+
+fun Application.cleanupUnverifiedUsersPreDay() {
+ launch(Dispatchers.IO) {
+ while (true) {
+ log.trace("Cleaning up unverified users")
+ getUnverifiedUsers()?.filter { it.createdAt!! + 7.days < Clock.System.now() }?.forEach {
+ log.trace("Deleting unverified user ${it.username} (id: ${it.id})")
+ it.delete()
+ }
+ log.trace(
+ "Finished cleaning up unverified users, next time cleaning up: {}",
+ (Clock.System.now() + 1.days).toLocalDateTime(TimeZone.currentSystemDefault())
+ )
+ delay(1.days)
+ }
+ }
+}
+
+private suspend fun getUnverifiedUsers(first: Int = 0): List? = adminClient.get(adminUrl {
+ appendPathSegments("users")
+ parameters.apply {
+ append("first", first.toString())
+ append("max", "100")
+ append("enabled", "false")
+ }
+}).let {
+ when {
+ it.status.isSuccess() -> it.body>()
+ else -> null
+ }
+}?.run {
+ if (size == 100) getUnverifiedUsers(first + 100)?.plus(this) else this
+}
+
infix fun Api.Auth.Type.CredentialType.provide(credential: String): UserQuery.() -> Unit = {
when (this@provide) {
Api.Auth.Type.CredentialType.Username -> username = credential
diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/OAuth.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/OAuth.kt
index 7659cb0..4d0ea41 100644
--- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/OAuth.kt
+++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/auth/OAuth.kt
@@ -21,6 +21,7 @@ package xyz.xfqlittlefan.fhraise.auth
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.forms.*
+import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -32,36 +33,36 @@ suspend fun HttpClient.getTokensByPassword(
clientSecret: String,
username: String,
verification: Api.Auth.Type.Verify.RequestBody.Verification
-) = runCatching {
- submitForm(
- url = keycloakTokenUrl,
- formParameters = parameters {
- append("client_id", clientId)
- append("client_secret", clientSecret)
- append("grant_type", "password")
- append("username", username)
- append("password", verification.value)
- verification.otp?.let { if (it.isNotBlank()) append("totp", it) }
- },
- ).body()
-}.getOrElse {
- logger.error("Failed to get tokens by password", it)
- null
+) = submitForm(
+ url = keycloakTokenUrl,
+ formParameters = parameters {
+ append("client_id", clientId)
+ append("client_secret", clientSecret)
+ append("grant_type", "password")
+ append("username", username)
+ append("password", verification.value)
+ verification.otp?.let { if (it.isNotBlank()) append("totp", it) }
+ },
+).let { response ->
+ runCatching { response.body() }.getOrElse {
+ logger.error("Failed to get tokens by password", Exception(response.bodyAsText(), it))
+ null
+ }
}
-suspend fun HttpClient.refreshTokens(clientId: String, clientSecret: String, refreshToken: String) = runCatching {
- submitForm(
- url = keycloakTokenUrl,
- formParameters = parameters {
- append("client_id", clientId)
- append("client_secret", clientSecret)
- append("grant_type", "refresh_token")
- append("refresh_token", refreshToken)
- },
- ).body()
-}.getOrElse {
- logger.error("Failed to refresh tokens", it)
- null
+suspend fun HttpClient.refreshTokens(clientId: String, clientSecret: String, refreshToken: String) = submitForm(
+ url = keycloakTokenUrl,
+ formParameters = parameters {
+ append("client_id", clientId)
+ append("client_secret", clientSecret)
+ append("grant_type", "refresh_token")
+ append("refresh_token", refreshToken)
+ },
+).let { response ->
+ runCatching { response.body() }.getOrElse {
+ logger.error("Failed to refresh tokens", Exception(response.bodyAsText(), it))
+ null
+ }
}
@Serializable
diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/Representations.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/Representations.kt
new file mode 100644
index 0000000..d5ef9d1
--- /dev/null
+++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/Representations.kt
@@ -0,0 +1,123 @@
+/*
+ * This file is part of Fhraise.
+ * Copyright (c) 2024 HSAS Foodies. All Rights Reserved.
+ *
+ * Fhraise is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ *
+ * Fhraise is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with Fhraise. If not, see .
+ */
+
+package xyz.xfqlittlefan.fhraise.models
+
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import xyz.xfqlittlefan.fhraise.serializers.InstantEpochMillisecondsSerializer
+
+@Serializable
+data class UserRepresentation(
+ var id: String? = null,
+ var username: String? = null,
+ var firstName: String? = null,
+ var lastName: String? = null,
+ var email: String? = null,
+ var emailVerified: Boolean? = null,
+ var attributes: Map>? = null,
+ var userProfileMetadata: UserProfileMetadata? = null,
+ var self: String? = null,
+ var origin: String? = null,
+ @SerialName("createdTimestamp") @Serializable(InstantEpochMillisecondsSerializer::class) var createdAt: Instant? = null,
+ var enabled: Boolean? = null,
+ var totp: Boolean? = null,
+ var federationLink: String? = null,
+ var serviceAccountClientId: String? = null,
+ var credentials: List? = null,
+ var disableableCredentialTypes: Set? = null,
+ var requiredActions: List? = null,
+ var federatedIdentities: List? = null,
+ var realmRoles: List? = null,
+ var clientRoles: Map>? = null,
+ var clientConsents: List? = null,
+ var notBefore: Int? = null,
+ var groups: List? = null,
+ var access: Map? = null
+)
+
+@Serializable
+data class UserProfileMetadata(
+ var attributes: List? = null,
+ var groups: List? = null
+)
+
+@Serializable
+data class UserProfileAttributeMetadata(
+ var name: String? = null,
+ var displayName: String? = null,
+ var required: Boolean? = null,
+ var readOnly: Boolean? = null,
+ var annotations: Map? = null,
+ var validators: Map>? = null,
+ var group: String? = null
+)
+
+@Serializable
+data class UserProfileAttributeGroupMetadata(
+ var name: String? = null,
+ var displayHeader: String? = null,
+ var displayDescription: String? = null,
+ var annotations: Map? = null
+)
+
+@Serializable
+data class CredentialRepresentation(
+ var id: String? = null,
+ var type: CredentialType? = null,
+ var userLabel: String? = null,
+ var createdDate: Long? = null,
+ var secretData: String? = null,
+ var credentialData: String? = null,
+ var priority: Int? = null,
+ var value: String? = null,
+ var temporary: Boolean? = null
+) {
+ @Serializable
+ enum class CredentialType {
+ @SerialName("secret")
+ Secret,
+
+ @SerialName("password")
+ Password,
+
+ @SerialName("totp")
+ Totp,
+
+ @SerialName("hotp")
+ Hotp,
+
+ @SerialName("kerberos")
+ Kerberos
+ }
+}
+
+@Serializable
+data class FederatedIdentityRepresentation(
+ var identityProvider: String? = null, var userId: String? = null, var userName: String? = null
+)
+
+@Serializable
+data class UserConsentRepresentation(
+ var clientId: String? = null,
+ var createDate: Long? = null,
+ var grantedClientScopes: List? = null,
+ var lastUpdatedDate: Long? = null
+)
diff --git a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/User.kt b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/User.kt
index 76d4ba9..9d7ef0a 100644
--- a/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/User.kt
+++ b/server/src/main/kotlin/xyz/xfqlittlefan/fhraise/models/User.kt
@@ -18,67 +18,7 @@
package xyz.xfqlittlefan.fhraise.models
-import kotlinx.serialization.Serializable
-
data class UserQuery(var username: String? = null, var phoneNumber: String? = null, var email: String? = null) {
val generatedUsername
get() = username ?: email ?: phoneNumber ?: error("No username, phone number, or email provided")
}
-
-@Serializable
-data class UserRepresentation(
- var access: Map? = null,
- var attributes: Map>? = null,
- var clientConsents: List? = null,
- var clientRoles: Map>? = null,
- var createdTimestamp: Long? = null,
- var credentials: List? = null,
- var disableableCredentialTypes: List? = null,
- var email: String? = null,
- var emailVerified: Boolean? = null,
- var enabled: Boolean? = null,
- var federatedIdentities: List? = null,
- var federationLink: String? = null,
- var firstName: String? = null,
- var groups: List? = null,
- var id: String? = null,
- var lastName: String? = null,
- var notBefore: Int? = null,
- var origin: String? = null,
- var realmRoles: List? = null,
- var requiredActions: List? = null,
- var self: String? = null,
- var serviceAccountClientId: String? = null,
- var totp: Boolean? = null,
- var username: String? = null
-)
-
-@Serializable
-data class UserConsentRepresentation(
- var clientId: String? = null,
- var createDate: Long? = null,
- var grantedClientScopes: List? = null,
- var lastUpdatedDate: Long? = null
-)
-
-@Serializable
-data class CredentialRepresentation(
- var algorithm: String? = null,
- var config: Map? = null,
- var counter: Int? = null,
- var createdDate: Long? = null,
- var device: String? = null,
- var digits: Int? = null,
- var hashIterations: Int? = null,
- var hashedSaltedvarue: String? = null,
- var period: Int? = null,
- var salt: String? = null,
- var temporary: Boolean? = null,
- var type: String? = null,
- var varue: String? = null
-)
-
-@Serializable
-data class FederatedIdentityRepresentation(
- var identityProvider: String? = null, var userId: String? = null, var userName: String? = null
-)
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
index c2a1ace..431d81a 100644
--- a/shared/build.gradle.kts
+++ b/shared/build.gradle.kts
@@ -43,6 +43,7 @@ kotlin {
val commonMain by getting {
dependencies {
implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.core)
implementation(libs.ktor.http)
implementation(libs.ktor.resources)
diff --git a/shared/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/serializers/InstantSerializers.kt b/shared/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/serializers/InstantSerializers.kt
new file mode 100644
index 0000000..8ad5c0e
--- /dev/null
+++ b/shared/src/commonMain/kotlin/xyz/xfqlittlefan/fhraise/serializers/InstantSerializers.kt
@@ -0,0 +1,39 @@
+/*
+ * This file is part of Fhraise.
+ * Copyright (c) 2024 HSAS Foodies. All Rights Reserved.
+ *
+ * Fhraise is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ *
+ * Fhraise is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with Fhraise. If not, see .
+ */
+
+package xyz.xfqlittlefan.fhraise.serializers
+
+import kotlinx.datetime.Instant
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+object InstantEpochMillisecondsSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG)
+
+ override fun serialize(encoder: Encoder, value: Instant) {
+ encoder.encodeLong(value.toEpochMilliseconds())
+ }
+
+ override fun deserialize(decoder: Decoder): Instant {
+ return Instant.fromEpochMilliseconds(decoder.decodeLong())
+ }
+}