Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,20 +43,25 @@ 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
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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
}
}
}
}
}
Expand All @@ -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(
Expand Down Expand Up @@ -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<Pair<String, String>> =
(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" }
}
24 changes: 23 additions & 1 deletion src/main/kotlin/dev/thunderid/android/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,29 @@ data class User(

data class UserProfile(
val id: String,
val claims: Map<String, Any> = emptyMap(),
val ouId: String? = null,
val type: String? = null,
val attributes: Map<String, Any> = 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<AttributeSchema>? = null,
val type: String? = null,
val unique: Boolean? = null,
)

data class UsersMeMetaResponse(
val schema: Map<String, AttributeSchema> = emptyMap(),
)

data class TokenResponse(
Expand Down
21 changes: 12 additions & 9 deletions src/main/kotlin/dev/thunderid/android/ThunderIDClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ class ThunderIDClient {

// MARK: - User & Profile

fun setCachedUser(user: User) {
currentUser = user
}

suspend fun getUser(): User {
requireInitialized()
currentUser?.let { return it }
Expand All @@ -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<String, AttributeSchema> {
requireInitialized()
return httpClient!!.get<UsersMeMetaResponse>("/users/me/meta").schema
}

suspend fun updateUserProfile(
payload: Map<String, Any>,
userId: String? = null,
): User {
suspend fun updateUserProfile(payload: Map<String, Any>): 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
Expand Down
6 changes: 4 additions & 2 deletions src/main/kotlin/dev/thunderid/android/ThunderIDConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/dev/thunderid/android/http/HttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ internal class HttpClient(
headers: Map<String, String> = emptyMap(),
): T = request("POST", path, body, requiresAuth, headers)

suspend inline fun <reified T : Any> put(
path: String,
body: Map<String, Any>,
requiresAuth: Boolean = true,
headers: Map<String, String> = emptyMap(),
): T = request("PUT", path, body, requiresAuth, headers)

suspend inline fun <reified T : Any> request(
method: String,
path: String,
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/dev/thunderid/compose/ThunderIDProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading