diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt index 7e506a6..9e4376e 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/HomeScreen.kt @@ -21,9 +21,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider @@ -40,8 +43,10 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -49,11 +54,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import dev.thunderid.android.User +import dev.thunderid.android.R import dev.thunderid.compose.LocalThunderID import dev.thunderid.compose.components.actions.SignOutButton +import dev.thunderid.compose.components.presentation.user.BaseUserProfile +import dev.thunderid.compose.components.presentation.user.ProfileField import dev.thunderid.compose.components.presentation.user.UserAvatar -import dev.thunderid.compose.components.presentation.user.UserProfile +import dev.thunderid.compose.components.presentation.user.UserProfileState +import dev.thunderid.compose.components.presentation.user.stringifyFieldValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -374,12 +382,6 @@ private fun ActionRow(label: String, onClick: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ProfileScreen(onBack: () -> Unit) { - val thunder = LocalThunderID.current - val displayName = remember(thunder.user) { userDisplayName(thunder.user) } - val email = remember(thunder.user) { thunder.user?.email ?: "" } - val userId = thunder.user?.sub ?: "—" - val attributes = remember(thunder.user) { userAttributes(thunder.user) } - Column( modifier = Modifier .fillMaxSize() @@ -402,36 +404,53 @@ private fun ProfileScreen(onBack: () -> Unit) { Spacer(Modifier.height(24.dp)) - // Avatar + identity - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - UserAvatar(size = 56.dp) - Spacer(Modifier.height(10.dp)) - Text(text = displayName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = TextPrimary) - Spacer(Modifier.height(2.dp)) - Text(text = email, fontSize = 13.sp, color = TextMuted) - } + // BaseUserProfile (unstyled) drives the /users/me data and edit/save state, so this + // screen keeps its own card design and just adds the pencil edit control to it. + BaseUserProfile { state -> + Column(modifier = Modifier.fillMaxWidth()) { + when { + state.isLoading && state.profile == null -> { + Text( + text = "Loading profile…", + fontSize = 13.sp, + color = TextMuted, + modifier = Modifier.padding(horizontal = 24.dp), + ) + } - Spacer(Modifier.height(32.dp)) + state.error != null -> { + Text( + text = state.error ?: "Failed to load profile.", + fontSize = 13.sp, + color = Color(0xFFD32F2F), + modifier = Modifier.padding(horizontal = 24.dp), + ) + } - // Account details - SectionHeader(title = "ACCOUNT DETAILS") - DetailCard { - DetailRow(label = "User ID") { - Text( - text = userId, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = TextMuted, - maxLines = 1, - ) - } - attributes.forEach { (label, value) -> - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(BorderLight)) - DetailRow(label = label) { - Text(text = value, fontSize = 13.sp, color = TextMuted) + else -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + UserAvatar(size = 56.dp) + Spacer(Modifier.height(10.dp)) + Text(text = state.displayName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = TextPrimary) + Spacer(Modifier.height(2.dp)) + state.email?.let { Text(text = it, fontSize = 13.sp, color = TextMuted) } + } + + Spacer(Modifier.height(32.dp)) + + SectionHeader(title = "ACCOUNT DETAILS") + DetailCard { + state.fields.forEachIndexed { index, field -> + if (index > 0) { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(BorderLight)) + } + ProfileFieldRow(field = field, state = state) + } + } + } } } } @@ -440,6 +459,68 @@ private fun ProfileScreen(onBack: () -> Unit) { } } +@Composable +private fun ProfileFieldRow( + field: ProfileField, + state: UserProfileState, +) { + val label = field.schema.displayName ?: field.schema.description ?: field.name + val isEditing = state.isEditing(field.name) + DetailRow(label = label) { + Column(horizontalAlignment = Alignment.End) { + if (isEditing && !field.isReadonly) { + Row(verticalAlignment = Alignment.CenterVertically) { + BasicTextField( + value = state.fieldValue(field), + onValueChange = { state.setFieldValue(field.name, it) }, + textStyle = TextStyle(fontSize = 13.sp, color = TextPrimary, textAlign = TextAlign.End), + singleLine = true, + modifier = Modifier.width(110.dp), + ) + Spacer(Modifier.width(18.dp)) + IconButton(onClick = { state.save(field.name) }, modifier = Modifier.size(22.dp)) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = "Save $label", + tint = SuccessGreen, + modifier = Modifier.size(16.dp), + ) + } + IconButton(onClick = { state.cancel(field.name) }, modifier = Modifier.size(22.dp)) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = "Cancel $label", + tint = TextMuted, + modifier = Modifier.size(16.dp), + ) + } + } + } else { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringifyFieldValue(field.rawValue).ifEmpty { "-" }, + fontSize = 13.sp, + color = TextMuted, + ) + if (!field.isReadonly) { + IconButton(onClick = { state.edit(field.name) }, modifier = Modifier.size(22.dp)) { + Icon( + painter = painterResource(R.drawable.ic_edit), + contentDescription = "Edit $label", + tint = PrimaryBlue, + modifier = Modifier.size(14.dp), + ) + } + } + } + } + state.fieldError(field.name)?.let { message -> + Text(text = message, fontSize = 11.sp, color = Color(0xFFD32F2F)) + } + } + } +} + @Composable private fun SectionHeader(title: String) { Text( @@ -728,48 +809,3 @@ private fun formatExpiresIn(expSeconds: Long?, nowSeconds: Long): String { } } -/** - * Every user attribute on the token as a label/value pair. Protocol claims are already - * filtered out by the SDK via [User.profileClaims]. - */ -private fun userAttributes(user: User?): List> = - (user?.profileClaims ?: emptyMap()) - .mapNotNull { (key, value) -> - formatClaim(value)?.let { claimLabel(key) to it } - } - .sortedBy { it.first.lowercase() } - -private fun formatClaim(value: Any?): String? = - when (value) { - is String -> value.takeIf { it.isNotEmpty() } - is Boolean -> if (value) "Yes" else "No" - is Number -> value.toString() - is org.json.JSONArray -> - (0 until value.length()) - .mapNotNull { idx -> formatClaim(value.opt(idx)) } - .takeIf { it.isNotEmpty() } - ?.joinToString(", ") - is List<*> -> - value - .mapNotNull { formatClaim(it) } - .takeIf { it.isNotEmpty() } - ?.joinToString(", ") - else -> null - } - -/** Humanizes a claim key for display: `given_name` -> "Given Name". */ -private fun claimLabel(key: String): String = - key - .replace("_", " ") - .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") - .split(" ") - .filter { it.isNotEmpty() } - .joinToString(" ") { it.replaceFirstChar(Char::uppercaseChar) } - -private fun userDisplayName(user: User?): String { - if (user == null) return "Guest" - val given = user["given_name"] as? String ?: "" - val family = user["family_name"] as? String ?: "" - val full = listOf(given, family).filter { it.isNotEmpty() }.joinToString(" ") - return full.ifEmpty { user.displayName?.takeIf { it.isNotEmpty() } ?: user.username ?: user.email?.substringBefore("@") ?: "Guest" } -} diff --git a/src/main/kotlin/dev/thunderid/android/Models.kt b/src/main/kotlin/dev/thunderid/android/Models.kt index a58d3c1..ad8cefe 100644 --- a/src/main/kotlin/dev/thunderid/android/Models.kt +++ b/src/main/kotlin/dev/thunderid/android/Models.kt @@ -59,7 +59,29 @@ data class User( data class UserProfile( val id: String, - val claims: Map = emptyMap(), + val ouId: String? = null, + val type: String? = null, + val attributes: Map = emptyMap(), + val display: String? = null, + val isReadOnly: Boolean = false, +) + +/** Attribute schema metadata returned by `GET /users/me/meta`. */ +data class AttributeSchema( + val credential: Boolean? = null, + val description: String? = null, + val displayName: String? = null, + val mutability: String? = null, + val readOnly: Boolean? = null, + val regex: String? = null, + val required: Boolean? = null, + val subAttributes: List? = null, + val type: String? = null, + val unique: Boolean? = null, +) + +data class UsersMeMetaResponse( + val schema: Map = emptyMap(), ) data class TokenResponse( diff --git a/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt index cc53d37..9480e2d 100644 --- a/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt @@ -227,6 +227,10 @@ class ThunderIDClient { // MARK: - User & Profile + fun setCachedUser(user: User) { + currentUser = user + } + suspend fun getUser(): User { requireInitialized() currentUser?.let { return it } @@ -245,18 +249,17 @@ class ThunderIDClient { suspend fun getUserProfile(): UserProfile { requireInitialized() - return httpClient!!.get("/scim2/Me") + return httpClient!!.get("/users/me") + } + + suspend fun getUserSchema(): Map { + requireInitialized() + return httpClient!!.get("/users/me/meta").schema } - suspend fun updateUserProfile( - payload: Map, - userId: String? = null, - ): User { + suspend fun updateUserProfile(payload: Map): UserProfile { requireInitialized() - val path = if (userId != null) "/scim2/Users/$userId" else "/scim2/Me" - val updated = User(claimsFrom(httpClient!!.post(path, payload))) - currentUser = updated - return updated + return httpClient!!.put("/users/me", mapOf("attributes" to payload)) } // MARK: - Flow Meta diff --git a/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt b/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt index 992e573..23ed4c7 100644 --- a/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt +++ b/src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt @@ -24,7 +24,9 @@ data class ThunderIDConfig( // Application Identity val applicationId: String? = null, val organizationHandle: String? = null, - // Platform Attestation — when enabled, a token from [attestationTokenProvider] is sent with every + // User Profile - whether user profile attributes come from /users/me or only from the OIDC access-token/userinfo claims. + val fetchUserProfile: Boolean = true, + // Platform Attestation - when enabled, a token from [attestationTokenProvider] is sent with every // native flow-initiate request (e.g. Google Play Integrity on Android). The provider is invoked by // the SDK; the app supplies it since obtaining a platform attestation token is app/platform-specific. val attestationEnabled: Boolean = false, @@ -34,7 +36,7 @@ data class ThunderIDConfig( // Storage & Platform val storage: StorageAdapter? = null, val instanceId: Int? = null, - // Development only — bypasses TLS certificate verification. Never use in production. + // Development only - bypasses TLS certificate verification. Never use in production. val allowInsecureConnections: Boolean = false, /** * Vendor/brand namespace used to derive default storage identifiers (e.g. EncryptedSharedPreferences file diff --git a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt index 2e14549..f738dc5 100644 --- a/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt +++ b/src/main/kotlin/dev/thunderid/android/http/HttpClient.kt @@ -42,6 +42,13 @@ internal class HttpClient( headers: Map = emptyMap(), ): T = request("POST", path, body, requiresAuth, headers) + suspend inline fun put( + path: String, + body: Map, + requiresAuth: Boolean = true, + headers: Map = emptyMap(), + ): T = request("PUT", path, body, requiresAuth, headers) + suspend inline fun request( method: String, path: String, diff --git a/src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt b/src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt index 776ef08..e121576 100644 --- a/src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt +++ b/src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt @@ -30,8 +30,8 @@ fun ThunderIDProvider( ) { val resolvedI18n = i18n ?: remember(config.vendor) { ThunderIDI18n(storageKey = "${config.vendor}_locale") } - val state = remember(client, resolvedI18n) { ThunderIDState(client, resolvedI18n) } val scope = rememberCoroutineScope() + val state = remember(client, resolvedI18n) { ThunderIDState(client, resolvedI18n, scope) } LaunchedEffect(config) { state.initialize(config) diff --git a/src/main/kotlin/dev/thunderid/compose/ThunderIDState.kt b/src/main/kotlin/dev/thunderid/compose/ThunderIDState.kt index 1c9276a..1ade846 100644 --- a/src/main/kotlin/dev/thunderid/compose/ThunderIDState.kt +++ b/src/main/kotlin/dev/thunderid/compose/ThunderIDState.kt @@ -10,13 +10,17 @@ import androidx.compose.runtime.setValue import dev.thunderid.android.ThunderIDClient import dev.thunderid.android.ThunderIDConfig import dev.thunderid.android.User +import dev.thunderid.android.UserProfile import dev.thunderid.compose.i18n.ThunderIDI18n +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** Reactive auth state for Compose. Held inside [rememberThunderIDState]. */ @Stable class ThunderIDState( val client: ThunderIDClient, val i18n: ThunderIDI18n, + private val scope: CoroutineScope, ) { var user by mutableStateOf(null) internal set @@ -27,14 +31,20 @@ class ThunderIDState( var error by mutableStateOf(null) internal set + /** Mirrors [dev.thunderid.android.ThunderIDConfig.fetchUserProfile]. */ + var fetchUserProfileEnabled: Boolean = true + internal set + val isSignedIn: Boolean get() = user != null internal suspend fun initialize(config: ThunderIDConfig) { isLoading = true try { + fetchUserProfileEnabled = config.fetchUserProfile client.initialize(config) val signedIn = runCatching { client.isSignedIn() }.getOrDefault(false) user = if (signedIn) runCatching { client.getUser() }.getOrNull() else null + if (signedIn && fetchUserProfileEnabled) launchUserProfileSync() isInitialized = true error = null } catch (e: Exception) { @@ -50,6 +60,7 @@ class ThunderIDState( try { val signedIn = client.isSignedIn() user = if (signedIn) client.getUser() else null + if (signedIn && fetchUserProfileEnabled) launchUserProfileSync() error = null } catch (e: Exception) { error = e.message @@ -58,6 +69,24 @@ class ThunderIDState( } } + /** Merges [profile]'s attributes into [user]'s claims and syncs the client's cache to match. */ + internal fun mergeUserProfile(profile: UserProfile) { + val current = user ?: return + val merged = current.copy(claims = current.claims + profile.attributes) + user = merged + client.setCachedUser(merged) + } + + // Launched on scope rather than awaited inline, since initialize()/refresh() are called + // from screens (e.g. SignIn) that unmount and cancel their own rememberCoroutineScope() + // right as user becomes non-null, which would cancel this fetch before it completes. + private fun launchUserProfileSync() { + scope.launch { + val profile = runCatching { client.getUserProfile() }.getOrNull() ?: return@launch + mergeUserProfile(profile) + } + } + fun setLocale(locale: String) { i18n.setLocale(locale) } diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt index 8bb728e..9f406af 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/user/UserProfile.kt @@ -6,70 +6,462 @@ package dev.thunderid.compose.components.presentation.user import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import dev.thunderid.android.AttributeSchema +import dev.thunderid.android.R +import dev.thunderid.android.User import dev.thunderid.android.UserProfile import dev.thunderid.compose.LocalThunderID -import dev.thunderid.compose.components.actions.BaseSignInButton +import dev.thunderid.compose.i18n.ThunderIDI18n import kotlinx.coroutines.launch -private val editableKeys = listOf("displayName", "phoneNumbers") +// Attribute names that are always readonly regardless of schema mutability (data contract). +private val readonlyFields = setOf("attributes", "id", "isReadOnly", "ouId", "username", "sub") -/** Editable user profile form (spec §8.4 Presentation). */ +// Default logical-name -> attribute-path fallback mappings. +private val defaultAttributeMappings: Map> = + mapOf( + "email" to listOf("emails", "email"), + "firstName" to listOf("name.givenName", "given_name"), + "lastName" to listOf("name.familyName", "family_name"), + "picture" to listOf("profile", "profileUrl", "picture", "URL"), + "username" to listOf("userName", "username", "user_name"), + ) + +/** A schema-described profile field merged with its current value, ready to render. */ +data class ProfileField( + val name: String, + val schema: AttributeSchema, + val rawValue: Any?, + val isReadonly: Boolean, + val isMultiValued: Boolean, +) + +/** + * Builds the ordered, filtered list of fields to render from the schema and current profile + * attributes. Every non-credential schema attribute is shown by default. + */ +internal fun buildProfileFields( + schema: Map, + profile: UserProfile, +): List = + schema.entries + .filter { (_, attr) -> attr.credential != true } + .sortedBy { it.key } + .map { (name, attr) -> + val rawValue = profile.attributes[name] + ProfileField( + name = name, + schema = attr, + rawValue = rawValue, + isReadonly = attr.readOnly == true || attr.mutability == "READ_ONLY" || name in readonlyFields, + isMultiValued = rawValue is List<*>, + ) + } + +/** Builds a read-only field list directly from JWT/userinfo claims (no schema to save against). */ +internal fun buildProfileFieldsFromClaims(user: User?): List = + (user?.profileClaims ?: emptyMap()) + .mapNotNull { (key, value) -> formatClaim(value)?.let { key to it } } + .sortedBy { (key, _) -> claimLabel(key).lowercase() } + .map { (key, formatted) -> + ProfileField( + name = key, + schema = AttributeSchema(displayName = claimLabel(key), readOnly = true, type = "STRING"), + rawValue = formatted, + isReadonly = true, + isMultiValued = false, + ) + } + +internal fun formatClaim(value: Any?): String? = + when (value) { + is String -> { + value.takeIf { it.isNotEmpty() } + } + + is Boolean -> { + if (value) "Yes" else "No" + } + + is Number -> { + value.toString() + } + + is org.json.JSONArray -> { + (0 until value.length()) + .mapNotNull { idx -> formatClaim(value.opt(idx)) } + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") + } + + is List<*> -> { + value + .mapNotNull { formatClaim(it) } + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") + } + + else -> { + null + } + } + +/** Humanizes a claim key for display: `given_name` -> "Given Name". */ +internal fun claimLabel(key: String): String = + key + .replace("_", " ") + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .split(" ") + .filter { it.isNotEmpty() } + .joinToString(" ") { it.replaceFirstChar(Char::uppercaseChar) } + +internal fun claimsDisplayName(user: User?): String { + if (user == null) return "Guest" + val given = user["given_name"] as? String ?: "" + val family = user["family_name"] as? String ?: "" + val full = listOf(given, family).filter { it.isNotEmpty() }.joinToString(" ") + return full.ifEmpty { + user.displayName?.takeIf { it.isNotEmpty() } ?: user.username ?: user.email?.substringBefore("@") ?: "Guest" + } +} + +/** Validates an edited field value against its schema: required first, then regex. */ +internal fun validateField( + schema: AttributeSchema, + value: String, +): String? { + val trimmed = value.trim() + if (schema.required == true && trimmed.isEmpty()) { + return "userProfile.validation.required" + } + val pattern = schema.regex + if (!pattern.isNullOrEmpty() && trimmed.isNotEmpty()) { + val matches = runCatching { Regex(pattern).containsMatchIn(trimmed) }.getOrNull() + if (matches == false) { + return "userProfile.validation.pattern" + } + } + return null +} + +/** + * Resolves a logical attribute name (firstName, email, picture...) to a value on [profile] by + * trying each candidate path in [mappings] in order, falling back to the built-in defaults. + */ +internal fun mapAttribute( + key: String, + mappings: Map>, + profile: UserProfile, +): String? { + val candidates = mappings[key] ?: defaultAttributeMappings[key] + if (candidates == null) { + return profile.attributes[key]?.toString() + } + candidates.forEach { path -> + resolveAttributePath(profile.attributes, path)?.let { return it.toString() } + } + return null +} + +/** Combines mapped firstName/lastName into a display name, falling back to username then id. */ +internal fun computeDisplayName( + mappings: Map>, + profile: UserProfile, +): String { + val firstName = mapAttribute("firstName", mappings, profile) + val lastName = mapAttribute("lastName", mappings, profile) + val fullName = listOfNotNull(firstName, lastName).joinToString(" ").trim() + if (fullName.isNotEmpty()) return fullName + return mapAttribute("username", mappings, profile) ?: profile.id +} + +private fun resolveAttributePath( + attributes: Map, + path: String, +): Any? { + var current: Any? = attributes + path.split(".").forEach { segment -> + current = + when (val step = current) { + is Map<*, *> -> step[segment] + else -> return null + } + } + return current +} + +/** Renders a raw field value for display/editing: joins list values, blanks out complex ones. */ +fun stringifyFieldValue(rawValue: Any?): String = + when (rawValue) { + null -> "" + is List<*> -> rawValue.joinToString(", ") { it.toString() } + is Map<*, *> -> "" + else -> rawValue.toString() + } + +/** Builds the nested `{"attributes": {...}}` payload segment for a single dot-path field save. */ +internal fun buildUpdatePayload( + name: String, + value: String, + isMultiValued: Boolean, +): Map { + val resolvedValue: Any = + if (isMultiValued) { + value.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } else { + value + } + return buildNestedMap(name.split("."), resolvedValue) +} + +private fun buildNestedMap( + segments: List, + value: Any, +): Map = + if (segments.size == 1) { + mapOf(segments[0] to value) + } else { + mapOf(segments[0] to buildNestedMap(segments.drop(1), value)) + } + +// Recursively merges overrides onto base. The backend requires every required attribute present +// in any save, so a single-field edit still needs the rest of the profile's attributes carried along. +internal fun deepMergeAttributes( + base: Map, + overrides: Map, +): Map { + val result = base.toMutableMap() + overrides.forEach { (key, value) -> + val baseValue = result[key] + @Suppress("UNCHECKED_CAST") + result[key] = + if (baseValue is Map<*, *> && value is Map<*, *>) { + deepMergeAttributes(baseValue as Map, value as Map) + } else { + value + } + } + return result +} + +/** State passed to the [BaseUserProfile] builder slot. */ +@Stable +class UserProfileState { + var profile by mutableStateOf(null) + internal set + var fields by mutableStateOf>(emptyList()) + internal set + var displayName by mutableStateOf("") + internal set + var email by mutableStateOf(null) + internal set + var isLoading by mutableStateOf(false) + internal set + var error by mutableStateOf(null) + internal set + + internal val editedValues = mutableStateMapOf() + internal val editingFields = mutableStateMapOf() + internal val fieldErrors = mutableStateMapOf() + + internal var onEdit: (String) -> Unit = {} + internal var onCancel: (String) -> Unit = {} + internal var onFieldChange: (String, String) -> Unit = { _, _ -> } + internal var onSave: (String) -> Unit = {} + + fun isEditing(name: String): Boolean = editingFields[name] == true + + fun fieldValue(field: ProfileField): String = editedValues[field.name] ?: stringifyFieldValue(field.rawValue) + + fun fieldError(name: String): String? = fieldErrors[name] + + fun edit(name: String) = onEdit(name) + + fun cancel(name: String) = onCancel(name) + + fun setFieldValue( + name: String, + value: String, + ) = onFieldChange(name, value) + + fun save(name: String) = onSave(name) +} + +/** Editable, schema-driven user profile form (spec §8.4 Presentation). */ @Composable fun UserProfile( modifier: Modifier = Modifier, + attributeMapping: Map> = emptyMap(), onSaved: (() -> Unit)? = null, onError: (() -> Unit)? = null, ) { - val state = LocalThunderID.current - val i18n = state.i18n - BaseUserProfile(modifier = modifier, onSaved = onSaved, onError = onError) { profile, fields, isLoading, error, save -> + val thunderState = LocalThunderID.current + val i18n = thunderState.i18n + BaseUserProfile( + modifier = modifier, + attributeMapping = attributeMapping, + onSaved = onSaved, + onError = onError, + ) { state -> Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - BasicText(i18n.resolve("userProfile.title")) + Text(i18n.resolve("userProfile.title"), style = MaterialTheme.typography.titleLarge) when { - isLoading && profile == null -> { - BasicText(i18n.resolve("userProfile.loading")) + state.isLoading && state.profile == null -> { + Text(i18n.resolve("userProfile.loading")) } - error != null -> { - BasicText(error) + state.error != null -> { + Text(state.error ?: i18n.resolve("userProfile.error.load")) } else -> { - editableKeys.forEach { key -> - BasicTextField( - value = fields[key]?.value ?: "", - onValueChange = { fields[key]?.value = it }, - modifier = - Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 44.dp) - .semantics { contentDescription = key }, + if (state.displayName.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + UserAvatar(size = 64.dp) + Spacer(Modifier.height(8.dp)) + Text(state.displayName, style = MaterialTheme.typography.titleMedium) + state.email?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + } + HorizontalDivider() + } + state.fields.forEach { field -> + ProfileFieldRow(field = field, state = state, i18n = i18n) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun ProfileFieldRow( + field: ProfileField, + state: UserProfileState, + i18n: ThunderIDI18n, +) { + val label = field.schema.displayName ?: field.schema.description ?: field.name + val isComplex = field.schema.type == "COMPLEX" && field.rawValue is Map<*, *> + val isEditing = state.isEditing(field.name) + Column(modifier = Modifier.fillMaxWidth()) { + Text(label, style = MaterialTheme.typography.labelMedium) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Box(modifier = Modifier.weight(1f)) { + when { + field.schema.type == "COMPLEX" && field.rawValue is Map<*, *> -> { + ComplexValueView(value = field.rawValue) + } + + isEditing && !field.isReadonly -> { + ProfileFieldEditor(field = field, state = state) + } + + else -> { + Text(stringifyFieldValue(field.rawValue).ifEmpty { "-" }) + } + } + } + when { + isComplex -> { + Unit + } + + isEditing && !field.isReadonly -> { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { state.save(field.name) }) { Text(i18n.resolve("userProfile.save")) } + TextButton(onClick = { state.cancel(field.name) }) { Text(i18n.resolve("userProfile.cancel")) } + } + } + + !field.isReadonly -> { + IconButton(onClick = { state.edit(field.name) }, modifier = Modifier.size(36.dp)) { + Icon( + painter = painterResource(R.drawable.ic_edit), + contentDescription = i18n.resolve("userProfile.edit"), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), ) } - BaseSignInButton( - label = if (isLoading) i18n.resolve("userProfile.saving") else i18n.resolve("userProfile.save"), - isLoading = isLoading, - ) { save() } } } } + state.fieldError(field.name)?.let { message -> + Text(message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun ProfileFieldEditor( + field: ProfileField, + state: UserProfileState, +) { + val value = state.fieldValue(field) + if (field.schema.type == "BOOLEAN") { + Checkbox( + checked = value == "true", + onCheckedChange = { state.setFieldValue(field.name, it.toString()) }, + ) + } else { + OutlinedTextField( + value = value, + onValueChange = { state.setFieldValue(field.name, it) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("thunderid-field-${field.name}") + .semantics { contentDescription = field.name }, + singleLine = true, + ) + } +} + +@Composable +private fun ComplexValueView(value: Map<*, *>) { + Column { + value.entries.forEach { (key, entryValue) -> + Row { + Text("$key: ", style = MaterialTheme.typography.labelSmall) + Text(entryValue.toString()) + } + } } } @@ -77,45 +469,111 @@ fun UserProfile( @Composable fun BaseUserProfile( modifier: Modifier = Modifier, + attributeMapping: Map> = emptyMap(), onSaved: (() -> Unit)? = null, onError: (() -> Unit)? = null, - content: @Composable (UserProfile?, Map>, Boolean, String?, () -> Unit) -> Unit, + content: @Composable (UserProfileState) -> Unit, ) { val thunderState = LocalThunderID.current + val i18n = thunderState.i18n val scope = rememberCoroutineScope() - var profile by remember { mutableStateOf(null) } - val fields = remember { editableKeys.associateWith { mutableStateOf("") } } - var isLoading by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } + val state = remember { UserProfileState() } + var schema by remember { mutableStateOf>(emptyMap()) } - LaunchedEffect(Unit) { - isLoading = true - error = null - try { - val p = thunderState.client.getUserProfile() - editableKeys.forEach { key -> fields[key]?.value = p.claims[key]?.toString() ?: "" } - profile = p - } catch (e: Exception) { - error = e.message - } - isLoading = false + fun applyProfile( + loadedSchema: Map, + loadedProfile: UserProfile, + ) { + state.profile = loadedProfile + state.fields = buildProfileFields(loadedSchema, loadedProfile) + state.displayName = computeDisplayName(attributeMapping, loadedProfile) + state.email = mapAttribute("email", attributeMapping, loadedProfile) + + // Reflect an edit (e.g. picture) immediately, without waiting for the next refresh. + thunderState.mergeUserProfile(loadedProfile) + } + + fun editField(name: String) { + val field = state.fields.firstOrNull { it.name == name } ?: return + state.editedValues[name] = stringifyFieldValue(field.rawValue) + state.editingFields[name] = true + state.fieldErrors.remove(name) } - val save = { + fun cancelField(name: String) { + state.editingFields[name] = false + state.editedValues.remove(name) + state.fieldErrors.remove(name) + } + + fun changeField( + name: String, + value: String, + ) { + state.editedValues[name] = value + } + + fun saveField(name: String) { + val field = state.fields.firstOrNull { it.name == name } ?: return + val value = state.editedValues[name] ?: stringifyFieldValue(field.rawValue) + val validationKey = validateField(field.schema, value) + if (validationKey != null) { + state.fieldErrors[name] = i18n.resolve(validationKey) + return + } + state.fieldErrors.remove(name) scope.launch { - isLoading = true - error = null + state.isLoading = true try { - thunderState.client.updateUserProfile(fields.mapValues { it.value.value }) - isLoading = false + val fieldPayload = buildUpdatePayload(name, value, field.isMultiValued) + val currentAttributes = state.profile?.attributes ?: emptyMap() + val payload = deepMergeAttributes(currentAttributes, fieldPayload) + val updated = thunderState.client.updateUserProfile(payload) + applyProfile(schema, updated) + state.editingFields[name] = false + state.editedValues.remove(name) onSaved?.invoke() } catch (e: Exception) { - error = e.message - isLoading = false + state.fieldErrors[name] = e.message ?: i18n.resolve("userProfile.error.save") onError?.invoke() + } finally { + state.isLoading = false } } } - Box(modifier = modifier) { content(profile, fields, isLoading, error) { save() } } + state.onEdit = ::editField + state.onCancel = ::cancelField + state.onFieldChange = ::changeField + state.onSave = ::saveField + + // No /users/me - render from thunderState.user's claims. Keyed on thunderState.user + // since it can still be loading when this composable first mounts. + LaunchedEffect(thunderState.user, thunderState.fetchUserProfileEnabled) { + if (thunderState.fetchUserProfileEnabled) return@LaunchedEffect + val user = thunderState.user + state.fields = buildProfileFieldsFromClaims(user) + state.displayName = claimsDisplayName(user) + state.email = user?.email + state.error = null + state.isLoading = false + } + + LaunchedEffect(Unit) { + if (!thunderState.fetchUserProfileEnabled) return@LaunchedEffect + state.isLoading = true + state.error = null + try { + val loadedSchema = thunderState.client.getUserSchema() + val loadedProfile = thunderState.client.getUserProfile() + schema = loadedSchema + applyProfile(loadedSchema, loadedProfile) + } catch (e: Exception) { + state.error = e.message + } finally { + state.isLoading = false + } + } + + Box(modifier = modifier) { content(state) } } diff --git a/src/main/kotlin/dev/thunderid/compose/i18n/DefaultStrings.kt b/src/main/kotlin/dev/thunderid/compose/i18n/DefaultStrings.kt index 295ea6c..8d8474e 100644 --- a/src/main/kotlin/dev/thunderid/compose/i18n/DefaultStrings.kt +++ b/src/main/kotlin/dev/thunderid/compose/i18n/DefaultStrings.kt @@ -25,6 +25,12 @@ object DefaultStrings { "userProfile.save" to "Save", "userProfile.loading" to "Loading profile…", "userProfile.saving" to "Saving…", + "userProfile.edit" to "Edit", + "userProfile.cancel" to "Cancel", + "userProfile.error.load" to "Failed to load profile.", + "userProfile.error.save" to "Failed to save changes.", + "userProfile.validation.required" to "This field is required.", + "userProfile.validation.pattern" to "This value is not valid.", "organization.unnamed" to "Unnamed organization", "organizationList.empty" to "No organizations", "organizationSwitcher.empty" to "No organizations", diff --git a/src/main/res/drawable/ic_check.xml b/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..b6ace89 --- /dev/null +++ b/src/main/res/drawable/ic_check.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/src/main/res/drawable/ic_close.xml b/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..4e1d807 --- /dev/null +++ b/src/main/res/drawable/ic_close.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/src/main/res/drawable/ic_edit.xml b/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..d2fc03c --- /dev/null +++ b/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt b/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt index f7e2db6..1392ff3 100644 --- a/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt +++ b/src/test/kotlin/dev/thunderid/compose/ComponentTests.kt @@ -70,6 +70,8 @@ class ComponentTests { "signUp.button", "userProfile.title", "userProfile.save", + "userProfile.edit", + "userProfile.cancel", "organizationList.empty", "createOrganization.submit", "languageSwitcher.title", diff --git a/src/test/kotlin/dev/thunderid/compose/components/presentation/user/UserProfileTest.kt b/src/test/kotlin/dev/thunderid/compose/components/presentation/user/UserProfileTest.kt new file mode 100644 index 0000000..a09b4a8 --- /dev/null +++ b/src/test/kotlin/dev/thunderid/compose/components/presentation/user/UserProfileTest.kt @@ -0,0 +1,370 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package dev.thunderid.compose.components.presentation.user + +import dev.thunderid.android.AttributeSchema +import dev.thunderid.android.User +import dev.thunderid.android.UserProfile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class UserProfileTest { + // ── buildProfileFields ────────────────────────────────────────────────── + + @Test + fun `buildProfileFields skips credential attributes`() { + val schema = mapOf("password" to AttributeSchema(credential = true)) + val profile = UserProfile(id = "u1", attributes = mapOf("password" to "secret")) + + val fields = buildProfileFields(schema, profile) + + assertTrue(fields.isEmpty()) + } + + @Test + fun `buildProfileFields shows a non-credential schema attribute even if it would be on the JS fallback skip-list`() { + // The fallback skip-list (roles.default, picture, etc.) only applies to JS's no-schema + // rendering path. This component always renders from a real schema, so nothing beyond + // credential attributes gets filtered out — e.g. "picture" must still render. + val schema = mapOf("picture" to AttributeSchema(type = "STRING")) + val profile = UserProfile(id = "u1", attributes = mapOf("picture" to "https://example.com/a.png")) + + val fields = buildProfileFields(schema, profile) + + assertEquals(1, fields.size) + assertEquals("picture", fields[0].name) + } + + @Test + fun `buildProfileFields marks a field readonly from schema readOnly flag`() { + val schema = mapOf("createdAt" to AttributeSchema(readOnly = true)) + val profile = UserProfile(id = "u1", attributes = mapOf("createdAt" to "2026-01-01")) + + val fields = buildProfileFields(schema, profile) + + assertEquals(1, fields.size) + assertTrue(fields[0].isReadonly) + } + + @Test + fun `buildProfileFields marks a field readonly from schema mutability`() { + val schema = mapOf("createdAt" to AttributeSchema(mutability = "READ_ONLY")) + val profile = UserProfile(id = "u1", attributes = mapOf("createdAt" to "2026-01-01")) + + val fields = buildProfileFields(schema, profile) + + assertTrue(fields[0].isReadonly) + } + + @Test + fun `buildProfileFields marks a field readonly from the fixed readonly-list regardless of schema`() { + val schema = mapOf("sub" to AttributeSchema(mutability = "READ_WRITE")) + val profile = UserProfile(id = "u1", attributes = mapOf("sub" to "u1")) + + val fields = buildProfileFields(schema, profile) + + assertTrue(fields[0].isReadonly) + } + + @Test + fun `buildProfileFields leaves an editable field writable`() { + val schema = mapOf("nickname" to AttributeSchema(mutability = "READ_WRITE")) + val profile = UserProfile(id = "u1", attributes = mapOf("nickname" to "Nik")) + + val fields = buildProfileFields(schema, profile) + + assertEquals(false, fields[0].isReadonly) + } + + @Test + fun `buildProfileFields detects a multi-valued field from the raw attribute value`() { + val schema = mapOf("phoneNumbers" to AttributeSchema()) + val profile = UserProfile(id = "u1", attributes = mapOf("phoneNumbers" to listOf("111", "222"))) + + val fields = buildProfileFields(schema, profile) + + assertTrue(fields[0].isMultiValued) + } + + // ── buildProfileFieldsFromClaims ──────────────────────────────────────── + + @Test + fun `buildProfileFieldsFromClaims strips protocol claims via User profileClaims`() { + val user = User(mapOf("sub" to "u1", "exp" to 123L, "iat" to 456L, "given_name" to "Ada")) + + val fields = buildProfileFieldsFromClaims(user) + + assertEquals(listOf("given_name"), fields.map { it.name }) + } + + @Test + fun `buildProfileFieldsFromClaims shows picture as a row, matching PR 25's original rendering`() { + val user = User(mapOf("picture" to "https://example.com/a.png", "email" to "ada@example.com")) + + val fields = buildProfileFieldsFromClaims(user) + + assertEquals(listOf("email", "picture"), fields.map { it.name }) + } + + @Test + fun `buildProfileFieldsFromClaims drops blank string values`() { + val user = User(mapOf("given_name" to "", "email" to "ada@example.com")) + + val fields = buildProfileFieldsFromClaims(user) + + assertEquals(listOf("email"), fields.map { it.name }) + } + + @Test + fun `buildProfileFieldsFromClaims drops nested object claims like assurance`() { + // The real "assurance" claim decodes to an org.json.JSONObject, which formatClaim also + // has no branch for; a plain Map exercises the same "unhandled type -> null" path + // without needing org.json's stub-only implementation in a plain JVM unit test. + val user = User(mapOf("assurance" to mapOf("aal" to "AAL1"), "email" to "ada@example.com")) + + val fields = buildProfileFieldsFromClaims(user) + + assertEquals(listOf("email"), fields.map { it.name }) + } + + @Test + fun `buildProfileFieldsFromClaims marks every field readonly`() { + val user = User(mapOf("given_name" to "Ada")) + + val fields = buildProfileFieldsFromClaims(user) + + assertTrue(fields[0].isReadonly) + } + + @Test + fun `buildProfileFieldsFromClaims derives a humanized display name from the claim key`() { + val user = User(mapOf("given_name" to "Ada")) + + val fields = buildProfileFieldsFromClaims(user) + + assertEquals("Given Name", fields[0].schema.displayName) + } + + // ── formatClaim ───────────────────────────────────────────────────────── + + @Test + fun `formatClaim renders a boolean as Yes or No`() { + assertEquals("Yes", formatClaim(true)) + assertEquals("No", formatClaim(false)) + } + + @Test + fun `formatClaim joins a list`() { + // Claims decoded from /oauth2/userinfo (Gson) surface array values as List; only claims + // decoded from a raw JWT surface org.json.JSONArray, which formatClaim also handles but + // can't be unit tested directly (org.json is a stub-only class outside a real Android runtime). + assertEquals("a, b", formatClaim(listOf("a", "b"))) + } + + @Test + fun `formatClaim drops a nested map`() { + assertNull(formatClaim(mapOf("aal" to "AAL1"))) + } + + // ── claimsDisplayName ─────────────────────────────────────────────────── + + @Test + fun `claimsDisplayName joins given_name and family_name`() { + val user = User(mapOf("given_name" to "Ada", "family_name" to "Lovelace")) + + assertEquals("Ada Lovelace", claimsDisplayName(user)) + } + + @Test + fun `claimsDisplayName falls back to email local part when no name claims exist`() { + val user = User(mapOf("email" to "ada@example.com")) + + assertEquals("ada", claimsDisplayName(user)) + } + + @Test + fun `claimsDisplayName falls back to Guest for a null user`() { + assertEquals("Guest", claimsDisplayName(null)) + } + + // ── validateField ──────────────────────────────────────────────────────── + + @Test + fun `validateField reports required error for a blank required field`() { + val error = validateField(AttributeSchema(required = true), " ") + + assertEquals("userProfile.validation.required", error) + } + + @Test + fun `validateField reports pattern error when regex does not match`() { + val error = validateField(AttributeSchema(regex = "^[0-9]+$"), "abc") + + assertEquals("userProfile.validation.pattern", error) + } + + @Test + fun `validateField accepts a value matching the regex`() { + val error = validateField(AttributeSchema(regex = "^[0-9]+$"), "12345") + + assertNull(error) + } + + @Test + fun `validateField ignores an invalid regex instead of blocking the save`() { + val error = validateField(AttributeSchema(regex = "([unclosed"), "anything") + + assertNull(error) + } + + @Test + fun `validateField allows a blank optional field`() { + val error = validateField(AttributeSchema(required = false), "") + + assertNull(error) + } + + // ── mapAttribute ──────────────────────────────────────────────────────── + + @Test + fun `mapAttribute resolves the first matching default fallback path`() { + val profile = UserProfile(id = "u1", attributes = mapOf("email" to "a@example.com")) + + val value = mapAttribute("email", emptyMap(), profile) + + assertEquals("a@example.com", value) + } + + @Test + fun `mapAttribute prefers the first candidate path over later ones`() { + val profile = + UserProfile( + id = "u1", + attributes = mapOf("emails" to "primary@example.com", "email" to "fallback@example.com"), + ) + + val value = mapAttribute("email", emptyMap(), profile) + + assertEquals("primary@example.com", value) + } + + @Test + fun `mapAttribute resolves a nested dot-path`() { + val profile = + UserProfile( + id = "u1", + attributes = mapOf("name" to mapOf("givenName" to "Ada")), + ) + + val value = mapAttribute("firstName", emptyMap(), profile) + + assertEquals("Ada", value) + } + + @Test + fun `mapAttribute honors a caller-supplied mapping override`() { + val profile = UserProfile(id = "u1", attributes = mapOf("customEmail" to "custom@example.com")) + + val value = mapAttribute("email", mapOf("email" to listOf("customEmail")), profile) + + assertEquals("custom@example.com", value) + } + + @Test + fun `mapAttribute returns null when no candidate path resolves`() { + val profile = UserProfile(id = "u1", attributes = emptyMap()) + + val value = mapAttribute("email", emptyMap(), profile) + + assertNull(value) + } + + // ── computeDisplayName ────────────────────────────────────────────────── + + @Test + fun `computeDisplayName joins mapped first and last name`() { + val profile = + UserProfile( + id = "u1", + attributes = mapOf("name" to mapOf("givenName" to "Ada", "familyName" to "Lovelace")), + ) + + assertEquals("Ada Lovelace", computeDisplayName(emptyMap(), profile)) + } + + @Test + fun `computeDisplayName falls back to username when no name is mapped`() { + val profile = UserProfile(id = "u1", attributes = mapOf("userName" to "ada")) + + assertEquals("ada", computeDisplayName(emptyMap(), profile)) + } + + @Test + fun `computeDisplayName falls back to the profile id as a last resort`() { + val profile = UserProfile(id = "u1", attributes = emptyMap()) + + assertEquals("u1", computeDisplayName(emptyMap(), profile)) + } + + // ── stringifyFieldValue / buildUpdatePayload ─────────────────────────── + + @Test + fun `stringifyFieldValue joins list values with a comma`() { + assertEquals("111, 222", stringifyFieldValue(listOf("111", "222"))) + } + + @Test + fun `buildUpdatePayload splits a comma-separated string for a multi-valued field`() { + val payload = buildUpdatePayload("phoneNumbers", "111, 222", isMultiValued = true) + + assertEquals(listOf("111", "222"), payload["phoneNumbers"]) + } + + @Test + fun `buildUpdatePayload nests a dot-path field name`() { + val payload = buildUpdatePayload("name.givenName", "Ada", isMultiValued = false) + + @Suppress("UNCHECKED_CAST") + val name = payload["name"] as Map + assertEquals("Ada", name["givenName"]) + } + + // ── deepMergeAttributes ───────────────────────────────────────────────── + + @Test + fun `deepMergeAttributes carries required base attributes through a single-field edit`() { + // The backend rejects a save missing any required attribute, even for a single-field + // edit, so a save must always carry the rest of the profile's attributes along. + val base = mapOf("username" to "ada", "email" to "ada@example.com", "given_name" to "Ada") + val overrides = mapOf("given_name" to "Ada Marie") + + val merged = deepMergeAttributes(base, overrides) + + assertEquals("ada", merged["username"]) + assertEquals("ada@example.com", merged["email"]) + assertEquals("Ada Marie", merged["given_name"]) + } + + @Test + fun `deepMergeAttributes recursively merges a nested map without dropping sibling keys`() { + val base = mapOf("name" to mapOf("givenName" to "Ada", "familyName" to "Lovelace")) + val overrides = mapOf("name" to mapOf("givenName" to "Ada Marie")) + + val merged = deepMergeAttributes(base, overrides) + + @Suppress("UNCHECKED_CAST") + val name = merged["name"] as Map + assertEquals("Ada Marie", name["givenName"]) + assertEquals("Lovelace", name["familyName"]) + } + + @Test + fun `deepMergeAttributes overwrites a non-map value with the override`() { + val merged = deepMergeAttributes(mapOf("picture" to "old-url"), mapOf("picture" to "new-url")) + + assertEquals("new-url", merged["picture"]) + } +}