feat: M10 — native SSO fixes + staff operations #21
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user