diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs index 8aa7c2fe59ecf9200e7654aaf412080c83affcd1..0059d50b74d9a1a085ff6df30af0ad67b909623c 100644 --- a/dashboard/src/auth.rs +++ b/dashboard/src/auth.rs @@ -728,7 +728,7 @@ pub async fn route(State(app): State>, request: Request) -> Result 254 || name.is_empty() { + if name.len() > 254 || (name.is_empty() && path == "/auth/password") { return Err(Error::new(400, "Enter your username.")); } auth.limit(&headers, &name)?; @@ -772,7 +772,10 @@ pub async fn route(State(app): State>, request: Request) -> Result>, request: Request) -> Result(0))? + .collect::, _>>()?; + ids.iter() + .map(|id| passkeys(&db, id)) + .collect::>>()? + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + }; if keys.is_empty() { return Err(Error::new( 401, "No passkey is available for that username. Try your password.", )); } - let (options, state) = auth.webauthn.start_passkey_authentication(&keys)?; + let (mut options, state) = auth.webauthn.start_passkey_authentication(&keys)?; + if name.is_empty() { + options.public_key.allow_credentials.clear(); + } let token = issue( &auth.db.lock().unwrap(), "authentication", - json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"]}), + json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"],"remember":body["remember"]}), 300, )?; return Ok(axum::Json(json!({"options":options,"token":token})).into_response()); @@ -817,6 +839,39 @@ pub async fn route(State(app): State>, request: Request) -> Result(0)?, r.get::<_, String>(1)?)))? + .collect::, _>>()?; + let mut found = None; + for (id, data) in rows { + let data: Value = serde_json::from_str(&data)?; + let passkey: Passkey = serde_json::from_value(data["passkey"].clone())?; + if passkey.cred_id().as_slice() == credential.get_credential_id() + && body["credential"]["response"]["userHandle"] == data["handle"] + { + if found.is_some() { + return Err(Error::new( + 401, + "That passkey couldn't identify your account.", + )); + } + found = Some(id); + } + } + found.ok_or_else(|| { + Error::new( + 401, + "That passkey couldn't identify your account. Try your password.", + ) + })? + }; let mut state = value["state"].clone(); let mut allowed: Vec = serde_json::from_value(state["ast"]["credentials"].clone())?; @@ -825,9 +880,7 @@ pub async fn route(State(app): State>, request: Request) -> Result(0))? - { + for stored in statement.query_map([&id], |r| r.get::<_, String>(0))? { let stored: Value = serde_json::from_str(&stored?)?; let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?; if stored["backupUnknown"] == true @@ -861,7 +914,7 @@ pub async fn route(State(app): State>, request: Request) -> Result>, request: Request) -> Result std::result::Result<(), Box> { request.headers_mut().remove("Studio-Proxy-Token"); } let path = request.uri().path().to_owned(); - let asset = path.starts_with("/assets/"); + let asset = path.starts_with("/assets/") || path.starts_with("/fonts/") || path == "/snowflake.svg"; if app.auth.ready() && !mcp::public(&path) && !path.starts_with("/auth/") diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs index 5e114f75b0f01fbe9ee5c47f5bc01a79e235d907..2ee38bff8766c99f1237f95c71c679bdb77e765f 100644 --- a/dashboard/src/shale.rs +++ b/dashboard/src/shale.rs @@ -9,6 +9,26 @@ use rmcp::{ }; use scraper::{Html, Selector}; +/// Shale rotates the session token while rendering comment deletion forms. +/// The final deletion form therefore carries the token accepted by every issue form. +fn issue_csrf(document: &Html) -> Result> { + let forms = Selector::parse("ul.timeline li.comment form[method=post]").unwrap(); + let kind = Selector::parse("input[name=t]").unwrap(); + let id = Selector::parse("input[name=id]").unwrap(); + let token = Selector::parse("input[type=hidden][name=csrf_token], input[hidden][name=csrf_token]").unwrap(); + let last = document.select(&forms).filter(|form| { + form.select(&kind).any(|input| input.attr("value") == Some("delete")) + && form.select(&id).any(|input| input.attr("value").is_some_and(|value| value.parse::().is_ok_and(|id| id > 0))) + }).last(); + let Some(form) = last else { return Ok(None) }; + let tokens: Vec<_> = form.select(&token).collect(); + match tokens.as_slice() { + [input] => input.attr("value").filter(|value| !value.is_empty()).map(|value| Some(value.to_owned())) + .ok_or_else(|| Error::new(502, "Shale's issue form changed. Open the issue to edit it.")), + _ => Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")), + } +} + pub struct Backend { pub(crate) origin: url::Url, http: reqwest::Client, @@ -450,6 +470,14 @@ impl ServerHandler for Shale { } } } + if matches!(name, "comment_issue" | "set_issue_status") { + if let Some(token) = issue_csrf(&document)? { + if !fields.contains_key("csrf_token") { + return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); + } + fields.insert("csrf_token".to_owned(), token); + } + } fields.insert("timezone".to_owned(), "UTC".to_owned()); fields.insert("tzoffset".to_owned(), "+00:00".to_owned()); post_target @@ -786,6 +814,18 @@ pub async fn oauth(app: Arc, request: Request) -> Response { mod tests { use super::*; #[test] + fn issue_csrf_uses_last_deletion_token_and_refuses_ambiguous_markup() { + let initial = "
"; + assert_eq!(issue_csrf(&Html::parse_document(initial)).unwrap(), None); + let deletion = |value: &str| format!("
"); + let page = format!("{initial}{}{}
", deletion("first"), deletion("latest")); + assert_eq!(issue_csrf(&Html::parse_document(&page)).unwrap().as_deref(), Some("latest")); + for bad in ["", "", ""] { + let page = format!("{initial}{}
  • {bad}
", deletion("older")); + assert!(issue_csrf(&Html::parse_document(&page)).is_err()); + } + } + #[test] fn repository_names_cannot_change_origin_or_path_segments() { let origin = url::Url::parse("https://shale.studio.test").unwrap(); for name in [ diff --git a/dashboard/web/pages/MCP.css b/dashboard/web/pages/MCP.css index 0d82f9ac87f0a76a3c4d3a1261c2e737569f5927..6f2c63475ab19412decb6089e2db0613cbe3a690 100644 --- a/dashboard/web/pages/MCP.css +++ b/dashboard/web/pages/MCP.css @@ -1,20 +1,19 @@ -.mcp-page { max-width: 1100px; } -.mcp-page h2 { font-size: 17px; margin: 0 0 12px; color: var(--text); } -.mcp-page h3 { font-size: 15px; margin: 0; } -.mcp-page p { color: var(--text-2); } -.mcp-navigation { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--line); padding-bottom: 12px; margin: 20px 0 24px; } -.mcp-navigation a { padding: 8px 12px; border-radius: 6px; color: var(--text-2); } -.mcp-navigation a:hover { background: var(--hover); } -.mcp-navigation a.active { background: var(--accent-wash); color: var(--accent); } -.mcp-catalogs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } -.mcp-catalog-card, .mcp-panel, .mcp-endpoint { border: 1px solid var(--line); border-radius: 10px; padding: 20px; background: var(--surface); } -.mcp-catalog-card:hover { border-color: var(--accent); } -.mcp-card-heading, .mcp-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.mcp-catalog-card p { min-height: 40px; } -.mcp-card-status { display: grid; gap: 6px; font-size: 12px; color: var(--muted); margin-top: 24px; } -.mcp-panel, .mcp-endpoint { margin: 20px 0; } -.mcp-endpoint p { margin: 0 0 16px; } -.mcp-endpoint .copy { max-width: 100%; } +/* Settings use the dashboard's controls and flat, divided sections. */ +.mcp-page { max-width: 1000px; } +.mcp-page h2 { font-size: 14px; font-weight: 600; margin: 0 0 8px; color: var(--text); } +.mcp-page h3 { font-size: 13px; margin: 0; } +.mcp-page p { color: var(--text-2); margin: 8px 0; } +.mcp-page > .segmented { margin-bottom: 8px; } +.mcp-panel, .mcp-endpoint { padding: 20px 0; border-bottom: 1px solid var(--line); } +.mcp-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.mcp-section-heading h2 { margin: 0; flex: none; } +.mcp-endpoint > p:first-child { margin: 0 0 16px; } +.mcp-endpoint .copy { max-width: 100%; min-width: 0; overflow-wrap: anywhere; white-space: normal; text-align: left; } +.mcp-endpoint .muted { font-size: 12px; } +.mcp-repositories, .mcp-install { margin: 12px 0; } +.mcp-repositories summary, .mcp-install summary { cursor: pointer; color: var(--text-2); } +.mcp-install h3 { margin: 12px 0 6px; } +.mcp-page .mcp-access-modes > label { padding: 6px 0; border: 0; background: none; } .mcp-actions { display: flex; flex-wrap: wrap; gap: 8px; } .mcp-access { padding: 0; margin: 20px 0; border: 0; min-width: 0; } .mcp-access legend { font-weight: 600; margin-bottom: 12px; } @@ -31,16 +30,15 @@ .mcp-client > .mcp-actions { align-self: start; } .mcp-client p { margin: 6px 0 0; overflow-wrap: anywhere; } .mcp-edit-access { grid-column: 1 / -1; } -.mcp-page table { width: 100%; text-align: left; border-collapse: collapse; } -.mcp-page th, .mcp-page td { padding: 12px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; } +.mcp-page table { margin: 16px 0; } .mcp-page form { margin: 16px 0; } .mcp-page form > label { display: flex; align-items: center; gap: 12px; } -.mcp-page input { padding: 8px; } -.mcp-page form > button { margin-top: 12px; } + +.mcp-page form:not(.mcp-actions) > button { margin-top: 12px; } .mcp-help { margin: 24px 0; } .mcp-help summary { cursor: pointer; } .mcp-help li { margin: 12px 0; } -.mcp-connector { padding: 20px; border: 1px solid var(--line); border-radius: 8px; margin: 16px 0; } +.mcp-connector { padding: 16px 0; margin: 16px 0; border-top: 1px solid var(--line); } .mcp-authorization { min-height: 100%; display: grid; place-items: center; padding: 40px 24px; box-sizing: border-box; } .mcp-approval { width: min(100%, 600px); padding: 32px; box-sizing: border-box; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; } .mcp-approval-brand { color: var(--accent); font-size: 13px; font-weight: 600; margin-bottom: 28px; } @@ -56,11 +54,9 @@ .mcp-approval-actions { display: flex; justify-content: space-between; gap: 16px; padding-top: 24px; border-top: 1px solid var(--line); margin-top: 24px; } .mcp-close { background: none; border: 0; color: var(--muted); cursor: pointer; margin-top: 20px; padding: 0; text-decoration: underline; } @media (max-width: 760px) { - .mcp-catalogs { grid-template-columns: 1fr; } - .mcp-catalog-card p { min-height: 0; } - .mcp-card-status { margin-top: 16px; } .mcp-client { grid-template-columns: 1fr; } .mcp-section-heading { flex-wrap: wrap; } + .mcp-endpoint .copy { width: 100%; } .mcp-repository-list > div { grid-template-columns: 1fr; gap: 4px; } .mcp-page form > label { flex-wrap: wrap; } .mcp-page input { max-width: 100%; min-width: 0; } diff --git a/dashboard/web/pages/MCP.tsx b/dashboard/web/pages/MCP.tsx index 8e6398fb36cde5f1a5423b3c90763906467bcd78..508ce6d7f569c62be383de0b88c5039147cdc299 100644 --- a/dashboard/web/pages/MCP.tsx +++ b/dashboard/web/pages/MCP.tsx @@ -1,4 +1,4 @@ -import { A, useParams } from "@solidjs/router"; +import { A, useNavigate, useParams } from "@solidjs/router"; import { parseResponse } from "hono/client"; import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; import { api, reason } from "../api.ts"; @@ -7,6 +7,7 @@ import { Checkbox } from "../components/Checkbox.tsx"; import { Copy } from "../components/Copy.tsx"; import { showConfirmDialog } from "../components/Dialog.tsx"; import { Loaded } from "../components/Loaded.tsx"; +import { TabBar } from "../components/TabBar.tsx"; import { toast } from "../components/Toast.tsx"; import { MCPAccess } from "./MCPAccess.tsx"; import type { Access, Catalog } from "../types/mcp.ts"; @@ -20,6 +21,8 @@ const descriptions: Record = { export function MCP() { const params = useParams<{ catalog?: string }>(); + const navigate = useNavigate(); + createEffect(() => { if (!params.catalog) navigate("/mcp/settings/observability", { replace: true }); }); const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get())); const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale", () => parseResponse(api.mcp.shale.$get())); @@ -58,25 +61,17 @@ export function MCP() { const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog); const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id); return <> -

{catalog()?.name || "MCP"}

{catalog() ? descriptions[catalog()!.id] : "Connect your AI clients and manage their access."}

- - - -

Open a connector to copy its installation URL or change a client’s access.

-
+

MCP

+ + {(catalog) => {catalog.name}} +

No connector here.

{(current) => <> -

Connect a client

Paste this URL into your AI client’s MCP settings, then approve access.

+
+

{descriptions[current().id]}

+

Connector URL

+

Add this URL to your AI client’s MCP settings, then approve access.

+

Shale account

@@ -86,30 +81,32 @@ export function MCP() {
{(account) => Link your Shale account to connect clients.

}> -

Repository access verified · Linked

-
{(resource) =>
{resource.name}{resource.description}
}
+

Account linked

+
{account().resources.length} repositories available
{(resource) =>
{resource.name}{resource.description}
}

Your account has no repositories yet.

}
+

Local machines

+
Install an agent

Install on each machine, then enter the pairing code below. The agent starts at login.

macOS or Linux

Windows PowerShell

-

Use your usual terminal, without administrator privileges. Codex or Claude Code must already be installed and signed in.

+

Use your usual terminal, without administrator privileges. Codex or Claude Code must already be installed and signed in.

{ event.preventDefault(); setBusy(true); try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); } catch (error) { toast(reason(error)); } finally { setBusy(false); } - }}> + }}>
No machines linked yet. Run the installer on a machine to link it.

}> - +
MachinePlatformConnection
{(machine) =>
MachinePlatformConnection
{machine.name}{machine.platform}{machine.online ? "Online" : "Offline"} @@ -142,8 +139,8 @@ export function MCP() {

Rerun the installer to update the agent or change allowed folders. Unlink removes the machine's access immediately.

New API key

Copy this key now. It won't be shown again.

+ -

Service access

Choose services when approving a client. Change its selection below at any time.

This connector grants read access to logs and traces.

Connected clients

No clients connected. Add the connector URL to your AI client to get started.

}>
{(client) =>
diff --git a/dashboard/web/pages/SignIn.css b/dashboard/web/pages/SignIn.css index 35ab4fa3446dd871c91bbe8d183d98e3788b1cc6..6dccbaa012e2320eb43676e97a642d0532726fd1 100644 --- a/dashboard/web/pages/SignIn.css +++ b/dashboard/web/pages/SignIn.css @@ -1,10 +1,10 @@ -.sign-in-page { min-height: 100dvh; display: grid; align-content: center; justify-items: center; gap: 24px; padding: 24px; } -.sign-in-brand { font-size: 24px; font-weight: 650; letter-spacing: -.5px; } -.sign-in-card { width: min(100%, 380px); padding: 28px; } -.sign-in-card h1 { margin: 0 0 24px; font-size: 22px; } -.sign-in-card form, .sign-in-card label { display: grid; gap: 8px; } -.sign-in-card form { gap: 18px; } -.sign-in-card p { margin: 0; font-size: 13px; line-height: 1.5; } -.sign-in-card label { color: var(--text-2); font-size: 13px; } -.sign-in-card input { width: 100%; height: 38px; } -.sign-in-card button { min-height: 38px; } +/* The original Keycloak theme stays shared; these adapt its document to the dashboard shell. */ +body { font: 16px "Name Sans", sans-serif; } +#root { display: contents; } +.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; } diff --git a/dashboard/web/pages/SignIn.tsx b/dashboard/web/pages/SignIn.tsx index a9bc5b02e32f758c79460de24a4fa4d075cf28e9..1b383a26dfc4bcf6f536102dd9937227923271fb 100644 --- a/dashboard/web/pages/SignIn.tsx +++ b/dashboard/web/pages/SignIn.tsx @@ -1,6 +1,7 @@ -import { createResource, createSignal, Show } from "solid-js"; +import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"; import { authReason, authRequest } from "../auth.ts"; -import "./SignIn.css"; +import theme from "../../../service/keycloak/theme/login/resources/css/styles.css?inline"; +import adjustments from "./SignIn.css?inline"; export function SignIn() { const params = new URLSearchParams(window.location.search); @@ -9,50 +10,106 @@ export function SignIn() { const [username, setUsername] = createSignal(""); const [password, setPassword] = createSignal(""); const [email, setEmail] = createSignal(""); + const [remember, setRemember] = createSignal(false); + const [visible, setVisible] = createSignal(false); const [busy, setBusy] = createSignal(false); const [error, setError] = createSignal(""); - const complete = async (passkey = false) => { + const [notice, setNotice] = createSignal(""); + 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") ?? "/" }); + const passkey = async (automatic = false) => { if (!status() || busy()) return; - setBusy(true); setError(""); + conditional?.abort(); + if (!automatic) { setBusy(true); setError(""); setNotice(""); } + const controller = new AbortController(); + conditional = controller; + let selected = false; try { - const body = { csrf: status()!.csrf, username: username(), password: password(), email: email(), setup, - flow: params.get("flow") ?? "", next: params.get("next") ?? "/" }; - let result: { next: string }; - if (passkey) { - const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", body); - const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey) }); - if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password."); - result = await authRequest("passkey/finish", { csrf: body.csrf, token, credential: credential.toJSON() }); - } else result = await authRequest(setup ? "setup" : "password", body); + const request = { ...body(), username: automatic ? "" : username() }; + const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", request); + if (controller.signal.aborted || disposed) return; + const credential = await navigator.credentials.get({ + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey), + mediation: automatic ? "conditional" : "optional", signal: controller.signal, + }); + 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() }); window.location.assign(result.next); } catch (failure) { - setError(authReason(failure)); - } finally { setBusy(false); } + 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); + })().catch(() => {}); + }); + const complete = async () => { + if (!status() || busy()) return; + conditional?.abort(); setBusy(true); setError(""); setNotice(""); + try { + const result = await authRequest<{ next: string }>(setup ? "setup" : "password", body()); + window.location.assign(result.next); + } catch (failure) { setError(authReason(failure)); } + finally { setBusy(false); } + }; + onMount(() => { + const title = document.title; + document.title = "sso (snow sign on)"; + onCleanup(() => { document.title = title; }); + }); + onCleanup(() => { disposed = true; conditional?.abort(); }); return ( -
- -