package com.example.aiapp import androidx.compose.foundation.layout.Column 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.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator 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.getValue import androidx.compose.runtime.mutableIntStateOf 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.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * Relays a provider CLI's headless browser login without ever owning its credentials. * * The URL and code live only in this composition. The CLI process on [machineId] remains the one * OAuth client and the only writer of its credential file. */ @Composable fun ProviderLoginDialog( settings: ServerSettings, machineId: String, machineName: String, provider: String, onDismiss: () -> Unit, onSignedIn: () -> Unit, ) { val scope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current var login by remember(machineId, provider) { mutableStateOf(null) } var code by remember(machineId, provider) { mutableStateOf("") } var error by remember(machineId, provider) { mutableStateOf(null) } var retry by remember(machineId, provider) { mutableIntStateOf(0) } suspend fun follow(initial: ProviderLogin): ProviderLogin { var current = initial val wasSubmitting = initial.state == "submitting" while (current.state == "starting" || current.state == "submitting") { delay(400) current = withContext(Dispatchers.IO) { fetchProviderLogin( settings, machineId, provider, current.attempt, ) } login = current } if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) { current = current.copy( detail = "That code was not accepted. Copy the complete code and try again." ) login = current } return current } LaunchedEffect(machineId, provider, retry) { error = null code = "" login = null try { val started = withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) } login = started if (follow(started).state == "succeeded") { onSignedIn() } } catch (e: ApiException) { error = e.message } } fun dismiss() { login ?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") } ?.let { scope.launch(Dispatchers.IO) { runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) } } } onDismiss() } AlertDialog( onDismissRequest = ::dismiss, title = { Text("Sign in to Claude") }, text = { Column { Text( "Claude will sign in on $machineName. Open the authorization page, then " + "paste the code it gives you here." ) Spacer(Modifier.height(12.dp)) when (val current = login) { null -> if (error == null) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator() Text("Starting sign-in…") } } else -> when (current.state) { "starting", "submitting" -> Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator() Text( if (current.state == "submitting") "Checking code…" else "Starting sign-in…" ) } "waitingForCode" -> { TextButton( onClick = { runCatching { current.authorizationUrl?.let(uriHandler::openUri) } .onFailure { error = "Couldn't open the authorization page." } }, enabled = current.authorizationUrl != null, ) { Text("Open authorization page") } OutlinedTextField( value = code, onValueChange = { code = it }, label = { Text("Authorization code") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) current.detail?.let { Text(it, color = MaterialTheme.colorScheme.error) } } "succeeded" -> Text("Signed in on $machineName.") "cancelled" -> Text("Sign-in was cancelled.") else -> Text( current.detail ?: "Sign-in failed.", color = MaterialTheme.colorScheme.error, ) } } error?.let { Text(it, color = MaterialTheme.colorScheme.error) } } }, confirmButton = { val current = login when { current?.state == "waitingForCode" -> TextButton( onClick = { scope.launch { error = null try { val submitted = withContext(Dispatchers.IO) { submitProviderLoginCode( settings, machineId, provider, current.attempt, code, ) } login = submitted if (follow(submitted).state == "succeeded") { onSignedIn() } } catch (e: ApiException) { error = e.message } } }, enabled = code.isNotBlank(), ) { Text("Continue") } error != null || current?.state == "failed" || current?.state == "cancelled" -> TextButton(onClick = { retry++ }) { Text("Try again") } current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") } } }, dismissButton = { if (login?.state != "succeeded") { TextButton(onClick = ::dismiss) { Text("Cancel") } } }, ) }