From 692d41a6eabb6e6a88da2770eb3fc61e00dd75dd Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 5 Oct 2026 10:41:12 -0700 Subject: [PATCH] Use a two-step sign-in form with service-specific headings Keep Shale providers above the form, remove remember me, and preserve invitation and passkey flows. Resolve headings from validated client destinations and the existing service catalog. Assisted-by: gpt-6 --- dashboard/src/auth.rs | 42 +++++++++++++++++++++++++++++++ dashboard/src/core.rs | 2 +- dashboard/src/oidc.rs | 20 ++++++++++++--- dashboard/web/pages/SignIn.css | 8 +++--- dashboard/web/pages/SignIn.tsx | 46 +++++++++++++++++++++------------- tools/dashboard-auth-test.py | 1 + tools/dashboard-oidc-test.py | 4 +++ 7 files changed, 98 insertions(+), 25 deletions(-) diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs index fecc2df0fb97e991f6039153259330fe412805f0..87b0a25bbf73d835b578d973e142687db4cb3e8b 100644 --- a/dashboard/src/auth.rs +++ b/dashboard/src/auth.rs @@ -681,6 +681,48 @@ pub async fn route(State(app): State>, request: Request) -> Result) -> Result> { +pub(crate) async fn launcher(app: Arc) -> Result> { let real = tokio::fs::canonicalize(&app.repo).await?; let state = app.clone(); app.cache.get(format!("launcher:{}",real.display()),Duration::from_secs(365*86400),move || async move { diff --git a/dashboard/src/oidc.rs b/dashboard/src/oidc.rs index 3458f8397d674de3b8c30b654d8020ecd041db03..90439f5a8192dc6f96da9d64deda923100dc2d90 100644 --- a/dashboard/src/oidc.rs +++ b/dashboard/src/oidc.rs @@ -57,7 +57,7 @@ fn guests_allowed(id: &str, config: &Value) -> bool { config["allowGuests"] == true && (id == "shale" || id.starts_with("shale-preview-")) } -pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result { +pub(crate) fn sign_in_target(auth: &auth::Store, next: &str) -> Result<(url::Url, Value)> { if next.len() > 8192 || next.contains('\\') || next.chars().any(char::is_control) { return Err(invalid("invalid_request")); } @@ -79,14 +79,28 @@ pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result { .map(String::as_str) .unwrap_or_default(); let config = client(&auth.db.lock().unwrap(), id)?; - if !guests_allowed(id, &config) - || query.get("response_type").map(String::as_str) != Some("code") + if query.get("response_type").map(String::as_str) != Some("code") || !array(&config["redirectUris"]) .iter() .any(|v| v.as_str() == query.get("redirect_uri").map(String::as_str)) { return Err(invalid("access_denied")); } + Ok((target, config)) +} + +pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result { + let (target, config) = sign_in_target(auth, next)?; + let query = fields(target.query().unwrap_or_default())?; + if !guests_allowed( + query + .get("client_id") + .map(String::as_str) + .unwrap_or_default(), + &config, + ) { + return Err(invalid("access_denied")); + } Ok(format!( "{}?{}", target.path(), diff --git a/dashboard/web/pages/SignIn.css b/dashboard/web/pages/SignIn.css index efa889601b3f75c39c67f4fafdab262145c20d95..9f328b5669304c919a02729997af9aefc4558aeb 100644 --- a/dashboard/web/pages/SignIn.css +++ b/dashboard/web/pages/SignIn.css @@ -4,11 +4,11 @@ body { font: 16px "Name Sans", sans-serif; } .pf-v5-c-login__main button, .pf-v5-c-login__main input { font-family: inherit; } .pf-v5-c-login__main input[type="submit"] { cursor: pointer; } .pf-v5-c-login__main [aria-disabled="true"], .pf-v5-c-login__main :disabled { opacity: .6; cursor: wait; } -.pf-v5-c-login__main .checkbox label { position: relative; } -.pf-v5-c-login__main .checkbox input { display: block; position: absolute; opacity: 0; } -.pf-v5-c-login__main .checkbox label:has(:focus-visible) { outline: 2px solid var(--primary); } .setup-intro { margin-bottom: 1rem; text-align: center; } -.guest-providers { margin-top: 1.5rem; } +.guest-providers { margin-bottom: 1.5rem; } +.sign-in-account { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } +.sign-in-account span { overflow-wrap: anywhere; } +.sign-in-account button { background: none; border: 0; padding: 0; color: var(--text); text-decoration: underline; cursor: pointer; } .pf-v5-c-login__main .guest-providers a { display: grid; grid-template-columns: 20px minmax(0, 1fr) 20px; diff --git a/dashboard/web/pages/SignIn.tsx b/dashboard/web/pages/SignIn.tsx index af65408853e5ae51397da4915000cbf685ed9726..5aefb84278fffce9c8f5388b714dcf7aebdd5e25 100644 --- a/dashboard/web/pages/SignIn.tsx +++ b/dashboard/web/pages/SignIn.tsx @@ -10,12 +10,13 @@ export function SignIn() { const setup = params.get("setup"); const statusQuery = new URLSearchParams(); if (setup) statusQuery.set("setup", setup); + if (params.get("flow")) statusQuery.set("flow", params.get("flow")!); if (params.get("next")) statusQuery.set("next", params.get("next")!); - const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; setup?: string; providers?: { id: string; name: string }[] }>(`status?${statusQuery}`)); + const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; service: string; setup?: string; providers?: { id: string; name: string }[] }>(`status?${statusQuery}`)); const [username, setUsername] = createSignal(""); const [password, setPassword] = createSignal(""); const [email, setEmail] = createSignal(""); - const [remember, setRemember] = createSignal(false); + const [passwordStep, setPasswordStep] = createSignal(false); const [visible, setVisible] = createSignal(false); const [busy, setBusy] = createSignal(false); const [error, setError] = createSignal(params.has("guest_error") ? "Guest sign-in wasn't completed. Choose a provider to try again." : ""); @@ -23,7 +24,7 @@ export function SignIn() { let conditional: AbortController | undefined; let disposed = false; const body = () => ({ csrf: status()!.csrf, username: username(), password: password(), email: email(), setup, - remember: remember(), flow: params.get("flow") ?? "", next: params.get("next") ?? "/" }); + remember: false, flow: params.get("flow") ?? "", next: params.get("next") ?? "/" }); const passkey = async (automatic = false) => { if (!status() || busy()) return; conditional?.abort(); @@ -41,20 +42,27 @@ export function SignIn() { }); if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password."); selected = true; setBusy(true); - const result = await authRequest<{ next: string }>("passkey/finish", { csrf: request.csrf, token, remember: remember(), credential: credential.toJSON() }); + const result = await authRequest<{ next: string }>("passkey/finish", { csrf: request.csrf, token, remember: false, credential: credential.toJSON() }); window.location.assign(result.next); } catch (failure) { if ((!automatic || selected) && !controller.signal.aborted) setError(authReason(failure)); } finally { if (!automatic || selected) setBusy(false); } }; createEffect(() => { - if (!setup && status()) void (async () => { - if (await PublicKeyCredential.isConditionalMediationAvailable?.() && !disposed) await passkey(true); + if (!setup && !passwordStep() && status()) void (async () => { + if (await PublicKeyCredential.isConditionalMediationAvailable?.() && !disposed && !passwordStep()) await passkey(true); })().catch(() => {}); }); const complete = async () => { if (!status() || busy()) return; - conditional?.abort(); setBusy(true); setError(""); setNotice(""); + conditional?.abort(); setError(""); setNotice(""); + if (!setup && !passwordStep()) { + if (!username().trim()) return; + setUsername(username().trim()); + setPasswordStep(true); + return; + } + setBusy(true); try { const result = await authRequest<{ next: string }>(setup ? "setup" : "password", body()); window.location.assign(result.next); @@ -73,18 +81,25 @@ export function SignIn() {
snow sign on
-

{setup ? "welcome" : "sign in to your account"}

+

{setup ? "welcome" : `sign in to ${status()?.service?.toLowerCase() ?? "snow globe"}`}

{notice()}
refetch()}>try again}> + + +
{ event.preventDefault(); void complete(); }}>
}>
- setUsername(event.currentTarget.value)} autofocus /> + setUsername(event.currentTarget.value)} autofocus ref={(input) => queueMicrotask(() => input.focus())} />
+ }>

Your account is {status()?.setup ?? "…"}. Add your email and choose a password.

@@ -92,29 +107,26 @@ export function SignIn() { setEmail(event.currentTarget.value)} autofocus />
+
- setPassword(event.currentTarget.value)} /> + setPassword(event.currentTarget.value)} ref={(input) => { if (!setup) queueMicrotask(() => input.focus()); }} />