diff --git a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt index 524a6f9..f31aa7b 100644 --- a/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt +++ b/samples/quickstart/src/main/kotlin/dev/thunderid/quickstart/AuthScreen.kt @@ -198,9 +198,7 @@ fun AuthScreen(applicationId: String) { onForgotPassword = { showSheet = "recover" }, onSignUp = { showSheet = "signup" }, ) - "signup" -> SignUpSheetContent( - onSignIn = { showSheet = "login" }, - ) + "signup" -> SignUpSheetContent() "recover" -> RecoverSheetContent( onBackToSignIn = { showSheet = "login" }, ) @@ -251,7 +249,7 @@ private fun LoginSheetContent( } @Composable -private fun SignUpSheetContent(onSignIn: () -> Unit) { +private fun SignUpSheetContent() { Column( modifier = Modifier .fillMaxWidth() @@ -262,13 +260,6 @@ private fun SignUpSheetContent(onSignIn: () -> Unit) { SheetTitle("Create account") Spacer(Modifier.height(8.dp)) SignUp(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) - Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Already have an account?", fontSize = 14.sp, color = TextMuted) - TextButton(onClick = onSignIn) { - Text("Sign in", color = PrimaryBlue, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - } - } } } diff --git a/src/main/kotlin/dev/thunderid/android/Models.kt b/src/main/kotlin/dev/thunderid/android/Models.kt index 18a9976..cda1278 100644 --- a/src/main/kotlin/dev/thunderid/android/Models.kt +++ b/src/main/kotlin/dev/thunderid/android/Models.kt @@ -103,7 +103,7 @@ enum class FlowStatus { PROMPT_ONLY, INCOMPLETE, COMPLETE, ERROR } data class FlowStepData( val actions: List? = null, val inputs: List? = null, - val meta: Map? = null, + val meta: FlowMeta? = null, ) data class FlowAction( @@ -112,6 +112,9 @@ data class FlowAction( val nextNode: String? = null, val type: String? = null, val label: String? = null, + val eventType: String? = null, + val variant: String? = null, + @SerializedName("image") val icon: String? = null, ) data class FlowInput( @@ -119,3 +122,21 @@ data class FlowInput( val type: String? = null, val required: Boolean? = null, ) + +data class FlowMeta( + val components: List? = null, +) + +data class FlowComponent( + val id: String? = null, + val ref: String? = null, + val type: String? = null, + val category: String? = null, + val label: String? = null, + val placeholder: String? = null, + val variant: String? = null, + val eventType: String? = null, + val align: String? = null, + @SerializedName("image") val icon: String? = null, + val components: List? = null, +) diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt new file mode 100644 index 0000000..42e9d3c --- /dev/null +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GitHubButton.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package dev.thunderid.compose.components.actions.adapters + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.dp + +/** + * "Continue with GitHub" federated sign-in trigger, styled to match the outlined action + * buttons rendered below a SignIn form's "Or" divider. + */ +@Composable +fun GitHubButton( + label: String, + isLoading: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TriggerButtonStyle( + label = label, + isLoading = isLoading, + onClick = onClick, + modifier = modifier, + icon = { GitHubGlyph() }, + ) +} + +/** + * Simplified GitHub "octocat" silhouette, drawn with plain [Canvas] circles rather than ported + * SVG path data — a rounded head with two ears, visually recognizable without pixel-perfect + * brand fidelity. + */ +@Composable +private fun GitHubGlyph() { + val glyphColor = LocalContentColor.current + Canvas(modifier = Modifier.size(18.dp)) { + val headRadius = size.minDimension * 0.42f + val center = Offset(size.width / 2f, size.height / 2f) + val earRadius = size.minDimension * 0.14f + + // Ears. + drawCircle( + color = glyphColor, + radius = earRadius, + center = Offset(center.x - headRadius * 0.62f, center.y - headRadius * 0.62f), + ) + drawCircle( + color = glyphColor, + radius = earRadius, + center = Offset(center.x + headRadius * 0.62f, center.y - headRadius * 0.62f), + ) + // Head/body. + drawCircle(color = glyphColor, radius = headRadius, center = center) + } +} diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt new file mode 100644 index 0000000..6ace4b1 --- /dev/null +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/GoogleButton.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package dev.thunderid.compose.components.actions.adapters + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp + +/** + * "Continue with Google" federated sign-in trigger, styled to match the outlined action + * buttons rendered below a SignIn form's "Or" divider. + */ +@Composable +fun GoogleButton( + label: String, + isLoading: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TriggerButtonStyle( + label = label, + isLoading = isLoading, + onClick = onClick, + modifier = modifier, + icon = { GoogleGlyph() }, + ) +} + +/** + * Simplified four-color Google "G" mark, drawn with plain [Canvas] arcs rather than ported SVG + * path data — visually recognizable without pixel-perfect brand fidelity. + */ +@Composable +private fun GoogleGlyph() { + Canvas(modifier = Modifier.size(18.dp)) { + val strokeWidth = size.minDimension * 0.22f + val radius = (size.minDimension - strokeWidth) / 2f + val center = Offset(size.width / 2f, size.height / 2f) + val topLeft = Offset(center.x - radius, center.y - radius) + val arcSize = Size(radius * 2f, radius * 2f) + val stroke = Stroke(width = strokeWidth) + + // Red: top arc. + drawArc( + color = Color(0xFFEA4335), + startAngle = 270f, + sweepAngle = 90f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = stroke, + ) + // Green: bottom-right arc. + drawArc( + color = Color(0xFF34A853), + startAngle = 0f, + sweepAngle = 90f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = stroke, + ) + // Yellow: bottom-left arc. + drawArc( + color = Color(0xFFFBBC05), + startAngle = 90f, + sweepAngle = 90f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = stroke, + ) + // Blue: top-left arc, plus the crossbar of the "G". + drawArc( + color = Color(0xFF4285F4), + startAngle = 180f, + sweepAngle = 90f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = stroke, + ) + drawLine( + color = Color(0xFF4285F4), + start = center, + end = Offset(center.x + radius, center.y), + strokeWidth = strokeWidth, + ) + } +} diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/OutlinedTriggerButton.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/OutlinedTriggerButton.kt new file mode 100644 index 0000000..d057ccc --- /dev/null +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/OutlinedTriggerButton.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package dev.thunderid.compose.components.actions.adapters + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Generic outlined trigger button for `eventType: TRIGGER` actions with no dedicated brand + * adapter, using the label supplied by the flow schema and no icon. + */ +@Composable +fun OutlinedTriggerButton( + label: String, + isLoading: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TriggerButtonStyle( + label = label, + isLoading = isLoading, + onClick = onClick, + modifier = modifier, + icon = null, + ) +} diff --git a/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/TriggerButtonStyle.kt b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/TriggerButtonStyle.kt new file mode 100644 index 0000000..770473c --- /dev/null +++ b/src/main/kotlin/dev/thunderid/compose/components/actions/adapters/TriggerButtonStyle.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package dev.thunderid.compose.components.actions.adapters + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Shared outlined-button chrome for `eventType: TRIGGER` (federated login) actions: an icon + * slot, a label, and a rounded-rect stroke — matching the "Continue with X" buttons rendered + * below a SignIn form's "Or" divider. + */ +@Composable +fun TriggerButtonStyle( + label: String, + isLoading: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, +) { + OutlinedButton( + onClick = onClick, + enabled = !isLoading, + modifier = modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, LocalContentColor.current.copy(alpha = 0.4f)), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.invoke() + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } + } + } +} diff --git a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt index 33dae2f..3bcd863 100644 --- a/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt +++ b/src/main/kotlin/dev/thunderid/compose/components/presentation/auth/SignIn.kt @@ -21,10 +21,14 @@ package dev.thunderid.compose.components.presentation.auth import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,23 +40,37 @@ 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.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import dev.thunderid.android.EmbeddedFlowRequestConfig import dev.thunderid.android.EmbeddedFlowResponse import dev.thunderid.android.EmbeddedSignInPayload import dev.thunderid.android.FlowAction +import dev.thunderid.android.FlowComponent import dev.thunderid.android.FlowInput import dev.thunderid.android.FlowStatus import dev.thunderid.android.FlowType import dev.thunderid.compose.LocalThunderID import dev.thunderid.compose.ThunderIDState +import dev.thunderid.compose.components.actions.adapters.GitHubButton +import dev.thunderid.compose.components.actions.adapters.GoogleButton +import dev.thunderid.compose.components.actions.adapters.OutlinedTriggerButton +import dev.thunderid.compose.i18n.FlowTemplateResolver +import dev.thunderid.compose.i18n.ThunderIDI18n import kotlinx.coroutines.launch /** State passed to the [BaseSignIn] builder slot. */ @@ -62,6 +80,10 @@ class SignInState { internal set var actions by mutableStateOf>(emptyList()) internal set + var components by mutableStateOf>(emptyList()) + internal set + var templateResolver by mutableStateOf(null) + internal set var isLoading by mutableStateOf(false) internal set var error by mutableStateOf(null) @@ -89,8 +111,45 @@ class SignInState { flowId = response.flowId challengeToken = response.challengeToken inputs = response.data?.inputs ?: emptyList() - actions = response.data?.actions ?: emptyList() + val flowComponents = response.data?.meta?.components ?: emptyList() + components = flowComponents + actions = enrichActions(response.data?.actions ?: emptyList(), flowComponents) + } +} + +/** + * Fills in any `null` presentation fields on the flat `actions` array (label, eventType, + * variant, icon) from the matching `ACTION`-typed node in the component tree, matched by `ref` + * (falling back to `id`). Explicit flat values always win. + */ +private fun enrichActions( + actions: List, + components: List, +): List { + val actionComponents = flattenActionComponents(components) + return actions.map { action -> + val match = + actionComponents.firstOrNull { + (it.ref != null && it.ref == action.ref) || (it.id != null && it.id == action.id) + } ?: return@map action + action.copy( + label = action.label ?: match.label, + eventType = action.eventType ?: match.eventType, + variant = action.variant ?: match.variant, + icon = action.icon ?: match.icon, + ) + } +} + +private fun flattenActionComponents(components: List): List { + val result = mutableListOf() + for (component in components) { + if (component.type == "ACTION") { + result.add(component) + } + component.components?.let { result.addAll(flattenActionComponents(it)) } } + return result } /** Full app-native sign-in form (spec §8.4 Presentation). */ @@ -106,37 +165,268 @@ fun SignIn( BaseSignIn(applicationId = applicationId, modifier = modifier, onComplete = onComplete, onError = onError) { signInState -> Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { signInState.error?.let { Text(it) } - signInState.inputs.forEach { input -> - val isPassword = input.type == "PASSWORD_INPUT" - OutlinedTextField( - value = signInState.fieldValue(input.name), - onValueChange = { signInState.setField(input.name, it) }, - modifier = - Modifier - .fillMaxWidth() - .testTag("thunderid-field-${input.name}") - .semantics { contentDescription = input.name }, - placeholder = { Text(input.name) }, - visualTransformation = - if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = - KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Text), - singleLine = true, + if (signInState.components.isNotEmpty()) { + signInState.components.forEach { component -> + FlowComponentView( + component = component, + signInState = signInState, + i18n = i18n, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + signInState.inputs.forEach { input -> + val isPassword = input.type == "PASSWORD_INPUT" + OutlinedTextField( + value = signInState.fieldValue(input.name), + onValueChange = { signInState.setField(input.name, it) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("thunderid-field-${input.name}") + .semantics { contentDescription = input.name }, + placeholder = { Text(input.name) }, + visualTransformation = + if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = + KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Text), + singleLine = true, + ) + } + signInState.actions.forEach { action -> + Button( + onClick = { signInState.submit(action.id ?: action.ref ?: "") }, + enabled = !signInState.isLoading, + modifier = Modifier.fillMaxWidth().testTag("thunderid-action-${action.id ?: action.ref}"), + ) { + Text(action.label ?: i18n.resolve("signIn.submit")) + } + } + } + } + } +} + +/** + * Recursively renders a `meta.components` node returned by `GET /flow/meta`. Dispatch is based + * on [FlowComponent.type] first — `DIVIDER` and `RICH_TEXT` are handled explicitly even when the + * server also sets an explicit `category: "DISPLAY"` on them. + */ +@Composable +fun FlowComponentView( + component: FlowComponent, + signInState: SignInState, + i18n: ThunderIDI18n, + modifier: Modifier = Modifier, +) { + val resolver = signInState.templateResolver + when { + component.type == "DIVIDER" -> DividerRow(component = component, resolver = resolver, modifier = modifier) + component.type == "RICH_TEXT" -> { + val html = resolver?.resolve(component.label) ?: component.label ?: "" + if (html.isNotBlank()) { + RichTextView(html = html, modifier = modifier) + } + } + component.type == "TEXT" -> { + val text = resolver?.resolve(component.label) ?: component.label ?: "" + if (text.isNotBlank()) { + Text( + text = text, + modifier = modifier, + style = + if (component.variant == "HEADING_1") { + MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold) + } else { + MaterialTheme.typography.bodyMedium + }, + textAlign = if (component.align == "center") TextAlign.Center else TextAlign.Start, ) } - signInState.actions.forEach { action -> - Button( - onClick = { signInState.submit(action.id ?: action.ref ?: "") }, - enabled = !signInState.isLoading, - modifier = Modifier.fillMaxWidth().testTag("thunderid-action-${action.id ?: action.ref}"), - ) { - Text(action.label ?: i18n.resolve("signIn.submit")) + } + component.type == "BLOCK" -> { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + component.components?.forEach { child -> + FlowComponentView( + component = child, + signInState = signInState, + i18n = i18n, + modifier = Modifier.fillMaxWidth(), + ) } } } + component.type == "ACTION" -> { + ActionComponentView(component = component, signInState = signInState, i18n = i18n, modifier = modifier) + } + component.type?.endsWith("_INPUT") == true -> { + FieldComponentView(component = component, signInState = signInState, modifier = modifier) + } + else -> Unit + } +} + +@Composable +private fun FieldComponentView( + component: FlowComponent, + signInState: SignInState, + modifier: Modifier = Modifier, +) { + val ref = component.ref ?: component.id ?: return + val resolver = signInState.templateResolver + val resolvedLabel = + resolver?.resolve(component.label)?.takeIf { it.isNotBlank() } + ?: component.label?.takeIf { it.isNotBlank() } + ?: ref.replaceFirstChar { it.uppercase() } + val isPassword = component.type == "PASSWORD_INPUT" + OutlinedTextField( + value = signInState.fieldValue(ref), + onValueChange = { signInState.setField(ref, it) }, + modifier = + modifier + .fillMaxWidth() + .testTag("thunderid-field-$ref") + .semantics { contentDescription = ref }, + label = { Text(resolvedLabel) }, + placeholder = { Text(resolvedLabel) }, + visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = + KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Text), + singleLine = true, + ) +} + +@Composable +private fun ActionComponentView( + component: FlowComponent, + signInState: SignInState, + i18n: ThunderIDI18n, + modifier: Modifier = Modifier, +) { + val action = + signInState.actions.firstOrNull { + (it.ref != null && it.ref == component.ref) || (it.id != null && it.id == component.id) + } ?: return + val actionId = action.id ?: action.ref ?: return + val resolver = signInState.templateResolver + val label = + resolver?.resolve(action.label)?.takeIf { it.isNotBlank() } + ?: action.label?.takeIf { it.isNotBlank() } + ?: i18n.resolve("signIn.submit") + val isTrigger = action.eventType?.uppercase() == "TRIGGER" + val identity = ((action.icon ?: "") + (action.ref ?: "") + (action.label ?: "")).lowercase() + val taggedModifier = modifier.testTag("thunderid-action-$actionId") + + if (isTrigger) { + when { + identity.contains("google") -> + GoogleButton( + label = label, + isLoading = signInState.isLoading, + onClick = { signInState.submit(actionId) }, + modifier = taggedModifier, + ) + identity.contains("github") -> + GitHubButton( + label = label, + isLoading = signInState.isLoading, + onClick = { signInState.submit(actionId) }, + modifier = taggedModifier, + ) + else -> + OutlinedTriggerButton( + label = label, + isLoading = signInState.isLoading, + onClick = { signInState.submit(actionId) }, + modifier = taggedModifier, + ) + } + } else { + Button( + onClick = { signInState.submit(actionId) }, + enabled = !signInState.isLoading, + modifier = taggedModifier.fillMaxWidth(), + ) { + Text(label) + } + } +} + +@Composable +private fun DividerRow( + component: FlowComponent, + resolver: FlowTemplateResolver?, + modifier: Modifier = Modifier, +) { + val label = + resolver?.resolve(component.label)?.takeIf { it.isNotBlank() } + ?: component.label?.takeIf { it.isNotBlank() } + ?: "Or" + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text(text = label, style = MaterialTheme.typography.bodySmall) + HorizontalDivider(modifier = Modifier.weight(1f)) } } +private val RICH_TEXT_LINK_REGEX = Regex("]*href=\"([^\"]*)\"[^>]*>(.*?)", RegexOption.DOT_MATCHES_ALL) +private val RICH_TEXT_TAG_REGEX = Regex("<[^>]+>") + +private fun stripHtmlTags(text: String): String = text.replace(RICH_TEXT_TAG_REGEX, "") + +/** + * Renders a constrained HTML subset (`

`, ``, ``) returned by the + * server for `RICH_TEXT` components — e.g. "forgot password" / "sign up" links. All other tags + * are stripped; `` segments become clickable, colored, underlined spans that open the href + * via [LocalUriHandler]. + */ +@Composable +private fun RichTextView( + html: String, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + val linkColor = MaterialTheme.colorScheme.primary + val annotatedString = + remember(html, linkColor) { + buildAnnotatedString { + var lastIndex = 0 + for (match in RICH_TEXT_LINK_REGEX.findAll(html)) { + val range = match.range + if (range.first > lastIndex) { + append(stripHtmlTags(html.substring(lastIndex, range.first))) + } + val href = match.groupValues[1] + val linkText = stripHtmlTags(match.groupValues[2]) + pushStringAnnotation(tag = "URL", annotation = href) + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { + append(linkText) + } + pop() + lastIndex = range.last + 1 + } + if (lastIndex < html.length) { + append(stripHtmlTags(html.substring(lastIndex))) + } + } + } + ClickableText( + text = annotatedString, + modifier = modifier, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + onClick = { offset -> + annotatedString + .getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull() + ?.let { uriHandler.openUri(it.item) } + }, + ) +} + /** Unstyled base variant (spec §8.3). */ @Composable fun BaseSignIn( @@ -185,6 +475,12 @@ fun BaseSignIn( "actions=${response.data?.actions?.size} data=${response.data}", ) handleSignInResponse(response, signInState, thunderState, onComplete, onError) + try { + val metaMap = thunderState.client.getFlowMeta(applicationId) + signInState.templateResolver = FlowTemplateResolver(metaMap) + } catch (e: Exception) { + android.util.Log.w("SignInFlow", "Flow meta fetch failed", e) + } } catch (e: Exception) { android.util.Log.e("SignInFlow", "Flow initiation failed", e) signInState.error = e.message diff --git a/src/main/kotlin/dev/thunderid/compose/i18n/FlowTemplateResolver.kt b/src/main/kotlin/dev/thunderid/compose/i18n/FlowTemplateResolver.kt new file mode 100644 index 0000000..380d3a5 --- /dev/null +++ b/src/main/kotlin/dev/thunderid/compose/i18n/FlowTemplateResolver.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package dev.thunderid.compose.i18n + +/** + * Resolves `{{ t(key) }}` and `{{ meta(path) }}` template literals embedded in server-returned + * component labels and placeholders. + * + * `{{ t(signin:forms.credentials.title) }}` is resolved via the i18n translations returned by + * `GET /flow/meta`. Everything after the colon is used as a single lookup key into the + * namespace's translation map (not a further nested dot-walk). + * + * `{{ meta(application.name) }}` is resolved via a dot-path lookup on the flow meta map returned + * by `GET /flow/meta`. + * + * Any unrecognized expression is left unchanged. + */ +class FlowTemplateResolver(private val meta: Map) { + companion object { + private val TEMPLATE_REGEX = Regex("\\{\\{\\s*(.*?)\\s*\\}\\}") + } + + fun resolve(text: String?): String { + if (text.isNullOrEmpty()) return "" + if (!text.contains("{{")) return text + return TEMPLATE_REGEX.replace(text) { match -> + replace(match) + } + } + + private fun replace(match: MatchResult): String { + val content = match.groupValues[1].trim() + + if (content.startsWith("t(") && content.endsWith(")")) { + return resolveTranslation(content.substring(2, content.length - 1)) + } + + if (content.startsWith("meta(") && content.endsWith(")")) { + return resolveMeta(content.substring(5, content.length - 1)) + } + + return match.value + } + + private fun resolveTranslation(key: String): String { + // key: "signin:forms.credentials.title" -> namespace="signin", dotKey="forms.credentials.title" + val colonIdx = key.indexOf(':') + if (colonIdx == -1) return "" + + val namespace = key.substring(0, colonIdx) + val dotKey = key.substring(colonIdx + 1) + + val translations = (meta["i18n"] as? Map<*, *>)?.get("translations") as? Map<*, *> ?: return "" + val nsMap = translations[namespace] as? Map<*, *> ?: return "" + val value = nsMap[dotKey] + return if (value is String) value else "" + } + + private fun resolveMeta(path: String): String { + // path: "application.logoUrl" -> dot-path lookup on meta + var current: Any? = meta + for (part in path.split(".")) { + current = + if (current is Map<*, *>) { + current[part] + } else { + return "" + } + } + return when (current) { + is String -> current + null -> "" + else -> current.toString() + } + } +} diff --git a/src/test/kotlin/dev/thunderid/android/FlowMetaModelsTest.kt b/src/test/kotlin/dev/thunderid/android/FlowMetaModelsTest.kt new file mode 100644 index 0000000..a5b1e9d --- /dev/null +++ b/src/test/kotlin/dev/thunderid/android/FlowMetaModelsTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package dev.thunderid.android + +import com.google.gson.Gson +import dev.thunderid.compose.i18n.FlowTemplateResolver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression coverage for the real Flow Execution API payload where display metadata (labels, + * TRIGGER eventType, brand icons, DIVIDER/RICH_TEXT components) only lives under + * `data.meta.components`, not on the flat `data.actions`/`data.inputs` arrays. + */ +class FlowMetaModelsTest { + // language=JSON + private val fixture = + """ + { + "executionId": "019f52b4-4a25-79fd-b2a1-885b9a44dbe3", + "flowStatus": "INCOMPLETE", + "type": "VIEW", + "challengeToken": "efb3706d1a8db1a291da65cd49e213af6e63fe6379006dfbd96d545d3a920119", + "data": { + "inputs": [ + {"ref": "input_001", "identifier": "username", "type": "TEXT_INPUT", "required": true}, + {"ref": "input_002", "identifier": "password", "type": "PASSWORD_INPUT", "required": true} + ], + "actions": [ + {"ref": "action_001", "nextNode": "credentials_auth"}, + {"ref": "action_xoc0", "nextNode": "ID_0e5o"}, + {"ref": "action_zeye", "nextNode": "ID_dbxc"} + ], + "meta": { + "components": [ + {"align": "center", "category": "DISPLAY", "id": "text_001", "label": "Login", + "resourceType": "ELEMENT", "type": "TEXT", "variant": "HEADING_1"}, + {"category": "BLOCK", "components": [ + {"category": "FIELD", "hint": "", "id": "input_001", "inputType": "text", + "label": "{{ t(signin:forms.credentials.fields.username.label) }}", + "placeholder": "{{ t(signin:forms.credentials.fields.username.placeholder) }}", + "ref": "username", "required": true, "resourceType": "ELEMENT", "type": "TEXT_INPUT"}, + {"category": "FIELD", "hint": "", "id": "input_002", "inputType": "text", + "label": "{{ t(signin:forms.credentials.fields.password.label) }}", + "placeholder": "{{ t(signin:forms.credentials.fields.password.placeholder) }}", + "ref": "password", "required": true, "resourceType": "ELEMENT", + "type": "PASSWORD_INPUT"}, + {"category": "ACTION", "eventType": "SUBMIT", "id": "action_001", + "label": "{{ t(signin:forms.credentials.actions.submit.label) }}", + "resourceType": "ELEMENT", "type": "ACTION", "variant": "PRIMARY"} + ], "id": "block_001", "resourceType": "ELEMENT", "type": "BLOCK"}, + {"category": "DISPLAY", "id": "display_tfae", "label": "Or", + "resourceType": "ELEMENT", "type": "DIVIDER", "variant": "HORIZONTAL"}, + {"category": "ACTION", "components": [ + {"category": "ACTION", "eventType": "TRIGGER", "id": "action_xoc0", + "image": "assets/images/icons/google.svg", "label": "Continue with Google", + "resourceType": "ELEMENT", "type": "ACTION", "variant": "OUTLINED"} + ], "eventType": "TRIGGER", "id": "block_per3", "resourceType": "ELEMENT", "type": "BLOCK"}, + {"category": "ACTION", "components": [ + {"category": "ACTION", "eventType": "TRIGGER", "id": "action_zeye", + "image": "assets/images/icons/github.svg", "label": "Continue with GitHub", + "resourceType": "ELEMENT", "type": "ACTION", "variant": "OUTLINED"} + ], "eventType": "TRIGGER", "id": "block_dzuu", "resourceType": "ELEMENT", "type": "BLOCK"} + ] + } + } + } + """.trimIndent() + + @Test + fun `parses meta components from the real flow execution payload`() { + val response = Gson().fromJson(fixture, EmbeddedFlowResponse::class.java) + + val components = response.data?.meta?.components + assertNotNull(components) + assertEquals(5, components!!.size) + + val heading = components[0] + assertEquals("TEXT", heading.type) + assertEquals("center", heading.align) + assertEquals("HEADING_1", heading.variant) + + val block = components[1] + assertEquals("BLOCK", block.type) + assertEquals(3, block.components?.size) + + val divider = components[2] + assertEquals("DIVIDER", divider.type) + // The real payload sets an explicit category=DISPLAY on DIVIDER/RICH_TEXT. + assertEquals("DISPLAY", divider.category) + + val googleBlock = components[3] + assertEquals("BLOCK", googleBlock.type) + assertEquals("ACTION", googleBlock.category) + val googleAction = googleBlock.components?.first() + assertEquals("TRIGGER", googleAction?.eventType) + assertEquals("assets/images/icons/google.svg", googleAction?.icon) + + val githubBlock = components[4] + val githubAction = githubBlock.components?.first() + assertEquals("assets/images/icons/github.svg", githubAction?.icon) + } + + @Test + fun `flat actions carry no presentation metadata before enrichment`() { + val response = Gson().fromJson(fixture, EmbeddedFlowResponse::class.java) + val flatActions = response.data?.actions + assertNotNull(flatActions) + flatActions!!.forEach { action -> + assertNull(action.label) + assertNull(action.eventType) + assertNull(action.icon) + } + } + + @Test + fun `template resolver resolves translation and meta expressions`() { + val meta = + mapOf( + "i18n" to + mapOf( + "translations" to + mapOf( + "signin" to + mapOf( + "forms.credentials.fields.username.label" to "Username", + ), + ), + ), + "application" to mapOf("forgot_password_url" to "https://example.com/forgot"), + ) + val resolver = FlowTemplateResolver(meta) + + assertEquals( + "Username", + resolver.resolve("{{ t(signin:forms.credentials.fields.username.label) }}"), + ) + assertEquals( + "https://example.com/forgot", + resolver.resolve("{{meta(application.forgot_password_url)}}"), + ) + assertEquals("plain text", resolver.resolve("plain text")) + assertEquals("{{ unknown(foo) }}", resolver.resolve("{{ unknown(foo) }}")) + } + + @Test + fun `template resolver returns empty string for missing translation or meta path`() { + val resolver = FlowTemplateResolver(emptyMap()) + assertEquals("", resolver.resolve("{{ t(signin:missing.key) }}")) + assertEquals("", resolver.resolve("{{ meta(application.missing) }}")) + assertTrue(resolver.resolve(null).isEmpty()) + } +}