feat: M10 — native SSO fixes + staff operations #21

Merged
whitlocktech merged 6 commits from feat/m10-native-sso-fix into main 2026-07-21 21:38:49 +00:00
3 changed files with 85 additions and 16 deletions
Showing only changes of commit 422892f1ed - Show all commits

View File

@@ -320,7 +320,15 @@ private fun RunicNavHost(
ContactScreen() ContactScreen()
} }
composable(Routes.LOGIN) { composable(Routes.LOGIN) {
LoginScreen(onSignedIn = { navController.popBackStack() }) // Leave the login screen as soon as the session is established — whether by
// password or the SSO bridge. Keying off the shared session (not just the
// login VM's local flag) makes this robust to the deep-link/recomposition
// timing of the Custom-Tab return, which the LoginScreen callback alone can miss.
if (session is Session.SignedIn) {
LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) }
} else {
LoginScreen(onSignedIn = { navController.popBackStack() })
}
} }
composable(Routes.ACCOUNT) { composable(Routes.ACCOUNT) {
// Only meaningful while signed in; a sign-out (here or from the drawer) // Only meaningful while signed in; a sign-out (here or from the drawer)

View File

@@ -5,8 +5,10 @@ package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -15,14 +17,20 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -163,22 +171,25 @@ fun LoginScreen(
} }
} }
// ── Native SSO (§4.2, M9): a button per enabled provider that opens the // ── Native SSO (§4.2, M9): a single "Sign in with SSO" button that opens the
// Custom-Tab bridge and returns the user signed in. No website-login // Custom-Tab bridge. With one provider it launches straight through; with
// fallback — that page isn't mobile-formatted and can't deep-link the // several it presents a native picker (below). No website-login fallback —
// session back; a failed discovery offers a retry instead. // that page can't deep-link the session back; a failed discovery offers a retry.
var showSsoPicker by remember { mutableStateOf(false) }
when { when {
state.ssoProviders.isNotEmpty() -> { state.ssoProviders.isNotEmpty() -> {
state.ssoProviders.forEach { provider -> OutlinedButton(
OutlinedButton( onClick = {
onClick = { viewModel.onSsoProviderClick(provider) }, val providers = state.ssoProviders
enabled = !state.submitting, if (providers.size == 1) viewModel.onSsoProviderClick(providers.first())
modifier = Modifier else showSsoPicker = true
.fillMaxWidth() },
.padding(top = 12.dp), enabled = !state.submitting,
) { modifier = Modifier
Text(stringResource(R.string.login_sso_provider, provider.name)) .fillMaxWidth()
} .padding(top = 12.dp),
) {
Text(stringResource(R.string.login_sso_button))
} }
} }
@@ -214,6 +225,53 @@ fun LoginScreen(
Text(stringResource(R.string.login_forgot)) Text(stringResource(R.string.login_forgot))
} }
} }
if (showSsoPicker) {
SsoProviderPicker(
providers = state.ssoProviders,
onDismiss = { showSsoPicker = false },
onPick = { provider ->
showSsoPicker = false
viewModel.onSsoProviderClick(provider)
},
)
}
}
}
/**
* The native provider picker (§4.2): a bottom sheet listing the shard's enabled SSO
* providers so a single "Sign in with SSO" button can serve several IdPs without a
* website chooser page. Each row opens the Custom-Tab bridge for that provider.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SsoProviderPicker(
providers: List<com.runicgateway.app.data.api.dto.SsoProviderDto>,
onDismiss: () -> Unit,
onPick: (com.runicgateway.app.data.api.dto.SsoProviderDto) -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) {
Text(
text = stringResource(R.string.login_sso_pick_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
providers.forEach { provider ->
TextButton(
onClick = { onPick(provider) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
) {
Text(
text = stringResource(R.string.login_sso_provider, provider.name),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start,
)
}
}
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
} }
} }

View File

@@ -142,7 +142,10 @@
<string name="login_button">Sign in</string> <string name="login_button">Sign in</string>
<string name="login_register">Create an account</string> <string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</string> <string name="login_forgot">Forgot your password?</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). --> <!-- Single SSO entry point; the picker lists the shard's providers (native SSO, M9/M10). -->
<string name="login_sso_button">Sign in with SSO</string>
<string name="login_sso_pick_title">Choose a sign-in provider</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord". -->
<string name="login_sso_provider">Sign in with %1$s</string> <string name="login_sso_provider">Sign in with %1$s</string>
<string name="login_sso_loading">Loading sign-in options…</string> <string name="login_sso_loading">Loading sign-in options…</string>
<string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string> <string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string>