Rename setups and add provider reauthentication
This commit is contained in:
1 parent
e9a0f1b9da
commit
7d9df5d572
36 files changed
+1866
-684
No files matched your search
@@ -0,0 +1,215 @@
|
||||
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<ProviderLogin?>(null) }
|
||||
var code by remember(machineId, provider) { mutableStateOf("") }
|
||||
var error by remember(machineId, provider) { mutableStateOf<String?>(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") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user