authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 23:09:56-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
logfb0278c62cbe94f2d7874ec12d7a8a33a84b301d
treeb9ea83713bd981618a338ef5e9c8f6323dc5d826
parenta7246389ae8115bab036e4edf5f5240d06901251
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Preserve Snow Sign On and restore passkey autofill


14 files changed, 308 insertions(+), 121 deletions(-)

dashboard/src/auth.rs+68-12
......@@ -728,7 +728,7 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
728728 auth.csrf(&headers, &body)?;
729729 if path == "/auth/password" || path == "/auth/passkey/start" {
730730 let name = string(&body["username"]).trim().to_lowercase();
731 if name.len() > 254 || name.is_empty() {
731 if name.len() > 254 || (name.is_empty() && path == "/auth/password") {
732732 return Err(Error::new(400, "Enter your username."));
733733 }
734734 auth.limit(&headers, &name)?;
......@@ -772,7 +772,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
772772 ));
773773 }
774774 let id = id.unwrap();
775 let session = auth.create_session(&id, "dashboard", &headers, Some(&data))?;
775 let mut session = auth.create_session(&id, "dashboard", &headers, Some(&data))?;
776 if body["remember"] == false {
777 session = session.replace(&format!("; Max-Age={SESSION_TTL}"), "");
778 }
776779 let next = if !array(&user["requiredActions"]).is_empty() {
777780 "/account".into()
778781 } else {
......@@ -782,25 +785,44 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
782785 ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(),
783786 );
784787 }
785 if user["enabled"] != true {
788 if !name.is_empty() && user["enabled"] != true {
786789 return Err(Error::new(
787790 401,
788791 "No passkey is available for that username. Try your password.",
789792 ));
790793 }
791 let id = id.unwrap();
792 let keys = passkeys(&auth.db.lock().unwrap(), &id)?;
794 let keys = if let Some(id) = &id {
795 passkeys(&auth.db.lock().unwrap(), id)?
796 } else if name.is_empty() {
797 let db = auth.db.lock().unwrap();
798 let mut statement =
799 db.prepare("SELECT id FROM users WHERE json_extract(profile,'$.enabled')=1")?;
800 let ids = statement
801 .query_map([], |r| r.get::<_, String>(0))?
802 .collect::<std::result::Result<Vec<_>, _>>()?;
803 ids.iter()
804 .map(|id| passkeys(&db, id))
805 .collect::<Result<Vec<_>>>()?
806 .into_iter()
807 .flatten()
808 .collect()
809 } else {
810 Vec::new()
811 };
793812 if keys.is_empty() {
794813 return Err(Error::new(
795814 401,
796815 "No passkey is available for that username. Try your password.",
797816 ));
798817 }
799 let (options, state) = auth.webauthn.start_passkey_authentication(&keys)?;
818 let (mut options, state) = auth.webauthn.start_passkey_authentication(&keys)?;
819 if name.is_empty() {
820 options.public_key.allow_credentials.clear();
821 }
800822 let token = issue(
801823 &auth.db.lock().unwrap(),
802824 "authentication",
803 json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"]}),
825 json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"],"remember":body["remember"]}),
804826 300,
805827 )?;
806828 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
......@@ -817,6 +839,39 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
817839 }
818840 let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone())
819841 .map_err(|_| Error::new(400, "The browser couldn't return your passkey. Try again."))?;
842 let id = if let Some(id) = value["user"].as_str() {
843 id.to_owned()
844 } else {
845 let db = auth.db.lock().unwrap();
846 let mut statement = db.prepare(
847 "SELECT user_id,data FROM credentials WHERE kind='webauthn-passwordless'",
848 )?;
849 let rows = statement
850 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
851 .collect::<std::result::Result<Vec<_>, _>>()?;
852 let mut found = None;
853 for (id, data) in rows {
854 let data: Value = serde_json::from_str(&data)?;
855 let passkey: Passkey = serde_json::from_value(data["passkey"].clone())?;
856 if passkey.cred_id().as_slice() == credential.get_credential_id()
857 && body["credential"]["response"]["userHandle"] == data["handle"]
858 {
859 if found.is_some() {
860 return Err(Error::new(
861 401,
862 "That passkey couldn't identify your account.",
863 ));
864 }
865 found = Some(id);
866 }
867 }
868 found.ok_or_else(|| {
869 Error::new(
870 401,
871 "That passkey couldn't identify your account. Try your password.",
872 )
873 })?
874 };
820875 let mut state = value["state"].clone();
821876 let mut allowed: Vec<Credential> =
822877 serde_json::from_value(state["ast"]["credentials"].clone())?;
......@@ -825,9 +880,7 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
825880 let mut statement = db.prepare(
826881 "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
827882 )?;
828 for stored in
829 statement.query_map([string(&value["user"])], |r| r.get::<_, String>(0))?
830 {
883 for stored in statement.query_map([&id], |r| r.get::<_, String>(0))? {
831884 let stored: Value = serde_json::from_str(&stored?)?;
832885 let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?;
833886 if stored["backupUnknown"] == true
......@@ -861,7 +914,7 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
861914 "That passkey couldn't sign in. Try again or use your password.",
862915 )
863916 })?;
864 let id = string(&value["user"]);
917 let id = id.as_str();
865918 {
866919 let db = auth.db.lock().unwrap();
867920 let user = user(&db, id)?;
......@@ -922,7 +975,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
922975 ));
923976 }
924977 }
925 let session = auth.create_session(id, "dashboard", &headers, None)?;
978 let mut session = auth.create_session(id, "dashboard", &headers, None)?;
979 if body.get("remember").unwrap_or(&value["remember"]) == false {
980 session = session.replace(&format!("; Max-Age={SESSION_TTL}"), "");
981 }
926982 let next =
927983 if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() {
928984 "/account".into()
dashboard/src/main.rs+1-1
......@@ -511,7 +511,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
511511 request.headers_mut().remove("Studio-Proxy-Token");
512512 }
513513 let path = request.uri().path().to_owned();
514 let asset = path.starts_with("/assets/");
514 let asset = path.starts_with("/assets/") || path.starts_with("/fonts/") || path == "/snowflake.svg";
515515 if app.auth.ready()
516516 && !mcp::public(&path)
517517 && !path.starts_with("/auth/")
dashboard/src/shale.rs+40
......@@ -9,6 +9,26 @@ use rmcp::{
99};
1010use scraper::{Html, Selector};
1111
12/// Shale rotates the session token while rendering comment deletion forms.
13/// The final deletion form therefore carries the token accepted by every issue form.
14fn issue_csrf(document: &Html) -> Result<Option<String>> {
15 let forms = Selector::parse("ul.timeline li.comment form[method=post]").unwrap();
16 let kind = Selector::parse("input[name=t]").unwrap();
17 let id = Selector::parse("input[name=id]").unwrap();
18 let token = Selector::parse("input[type=hidden][name=csrf_token], input[hidden][name=csrf_token]").unwrap();
19 let last = document.select(&forms).filter(|form| {
20 form.select(&kind).any(|input| input.attr("value") == Some("delete"))
21 && form.select(&id).any(|input| input.attr("value").is_some_and(|value| value.parse::<u64>().is_ok_and(|id| id > 0)))
22 }).last();
23 let Some(form) = last else { return Ok(None) };
24 let tokens: Vec<_> = form.select(&token).collect();
25 match tokens.as_slice() {
26 [input] => input.attr("value").filter(|value| !value.is_empty()).map(|value| Some(value.to_owned()))
27 .ok_or_else(|| Error::new(502, "Shale's issue form changed. Open the issue to edit it.")),
28 _ => Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")),
29 }
30}
31
1232pub struct Backend {
1333 pub(crate) origin: url::Url,
1434 http: reqwest::Client,
......@@ -450,6 +470,14 @@ impl ServerHandler for Shale {
450470 }
451471 }
452472 }
473 if matches!(name, "comment_issue" | "set_issue_status") {
474 if let Some(token) = issue_csrf(&document)? {
475 if !fields.contains_key("csrf_token") {
476 return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it."));
477 }
478 fields.insert("csrf_token".to_owned(), token);
479 }
480 }
453481 fields.insert("timezone".to_owned(), "UTC".to_owned());
454482 fields.insert("tzoffset".to_owned(), "+00:00".to_owned());
455483 post_target
......@@ -786,6 +814,18 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
786814mod tests {
787815 use super::*;
788816 #[test]
817 fn issue_csrf_uses_last_deletion_token_and_refuses_ambiguous_markup() {
818 let initial = "<form method=post><input type=hidden name=t value=status><input type=hidden name=csrf_token value=old></form><form method=post><input type=hidden name=t value=comment><input type=hidden name=csrf_token value=old></form>";
819 assert_eq!(issue_csrf(&Html::parse_document(initial)).unwrap(), None);
820 let deletion = |value: &str| format!("<ul class=timeline><li class=comment><form method=post><input type=hidden name=t value=delete><input type=hidden name=id value=1><input type=hidden name=csrf_token value={value}></form></li></ul>");
821 let page = format!("{initial}{}{}<form method=post><input type=hidden name=csrf_token value=unrelated></form>", deletion("first"), deletion("latest"));
822 assert_eq!(issue_csrf(&Html::parse_document(&page)).unwrap().as_deref(), Some("latest"));
823 for bad in ["<input type=hidden name=csrf_token value=''>", "", "<input type=hidden name=csrf_token value=a><input type=hidden name=csrf_token value=b>"] {
824 let page = format!("{initial}{}<ul class=timeline><li class=comment><form method=post><input type=hidden name=t value=delete><input type=hidden name=id value=1>{bad}</form></li></ul>", deletion("older"));
825 assert!(issue_csrf(&Html::parse_document(&page)).is_err());
826 }
827 }
828 #[test]
789829 fn repository_names_cannot_change_origin_or_path_segments() {
790830 let origin = url::Url::parse("https://shale.studio.test").unwrap();
791831 for name in [
dashboard/web/pages/MCP.css+21-25
......@@ -1,20 +1,19 @@
1.mcp-page { max-width: 1100px; }
2.mcp-page h2 { font-size: 17px; margin: 0 0 12px; color: var(--text); }
3.mcp-page h3 { font-size: 15px; margin: 0; }
4.mcp-page p { color: var(--text-2); }
5.mcp-navigation { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--line); padding-bottom: 12px; margin: 20px 0 24px; }
6.mcp-navigation a { padding: 8px 12px; border-radius: 6px; color: var(--text-2); }
7.mcp-navigation a:hover { background: var(--hover); }
8.mcp-navigation a.active { background: var(--accent-wash); color: var(--accent); }
9.mcp-catalogs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
10.mcp-catalog-card, .mcp-panel, .mcp-endpoint { border: 1px solid var(--line); border-radius: 10px; padding: 20px; background: var(--surface); }
11.mcp-catalog-card:hover { border-color: var(--accent); }
12.mcp-card-heading, .mcp-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
13.mcp-catalog-card p { min-height: 40px; }
14.mcp-card-status { display: grid; gap: 6px; font-size: 12px; color: var(--muted); margin-top: 24px; }
15.mcp-panel, .mcp-endpoint { margin: 20px 0; }
16.mcp-endpoint p { margin: 0 0 16px; }
17.mcp-endpoint .copy { max-width: 100%; }
1/* Settings use the dashboard's controls and flat, divided sections. */
2.mcp-page { max-width: 1000px; }
3.mcp-page h2 { font-size: 14px; font-weight: 600; margin: 0 0 8px; color: var(--text); }
4.mcp-page h3 { font-size: 13px; margin: 0; }
5.mcp-page p { color: var(--text-2); margin: 8px 0; }
6.mcp-page > .segmented { margin-bottom: 8px; }
7.mcp-panel, .mcp-endpoint { padding: 20px 0; border-bottom: 1px solid var(--line); }
8.mcp-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
9.mcp-section-heading h2 { margin: 0; flex: none; }
10.mcp-endpoint > p:first-child { margin: 0 0 16px; }
11.mcp-endpoint .copy { max-width: 100%; min-width: 0; overflow-wrap: anywhere; white-space: normal; text-align: left; }
12.mcp-endpoint .muted { font-size: 12px; }
13.mcp-repositories, .mcp-install { margin: 12px 0; }
14.mcp-repositories summary, .mcp-install summary { cursor: pointer; color: var(--text-2); }
15.mcp-install h3 { margin: 12px 0 6px; }
16.mcp-page .mcp-access-modes > label { padding: 6px 0; border: 0; background: none; }
1817.mcp-actions { display: flex; flex-wrap: wrap; gap: 8px; }
1918.mcp-access { padding: 0; margin: 20px 0; border: 0; min-width: 0; }
2019.mcp-access legend { font-weight: 600; margin-bottom: 12px; }
......@@ -31,16 +30,15 @@
3130.mcp-client > .mcp-actions { align-self: start; }
3231.mcp-client p { margin: 6px 0 0; overflow-wrap: anywhere; }
3332.mcp-edit-access { grid-column: 1 / -1; }
34.mcp-page table { width: 100%; text-align: left; border-collapse: collapse; }
35.mcp-page th, .mcp-page td { padding: 12px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
33.mcp-page table { margin: 16px 0; }
3634.mcp-page form { margin: 16px 0; }
3735.mcp-page form > label { display: flex; align-items: center; gap: 12px; }
38.mcp-page input { padding: 8px; }
39.mcp-page form > button { margin-top: 12px; }
36
37.mcp-page form:not(.mcp-actions) > button { margin-top: 12px; }
4038.mcp-help { margin: 24px 0; }
4139.mcp-help summary { cursor: pointer; }
4240.mcp-help li { margin: 12px 0; }
43.mcp-connector { padding: 20px; border: 1px solid var(--line); border-radius: 8px; margin: 16px 0; }
41.mcp-connector { padding: 16px 0; margin: 16px 0; border-top: 1px solid var(--line); }
4442.mcp-authorization { min-height: 100%; display: grid; place-items: center; padding: 40px 24px; box-sizing: border-box; }
4543.mcp-approval { width: min(100%, 600px); padding: 32px; box-sizing: border-box; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
4644.mcp-approval-brand { color: var(--accent); font-size: 13px; font-weight: 600; margin-bottom: 28px; }
......@@ -56,11 +54,9 @@
5654.mcp-approval-actions { display: flex; justify-content: space-between; gap: 16px; padding-top: 24px; border-top: 1px solid var(--line); margin-top: 24px; }
5755.mcp-close { background: none; border: 0; color: var(--muted); cursor: pointer; margin-top: 20px; padding: 0; text-decoration: underline; }
5856@media (max-width: 760px) {
59 .mcp-catalogs { grid-template-columns: 1fr; }
60 .mcp-catalog-card p { min-height: 0; }
61 .mcp-card-status { margin-top: 16px; }
6257 .mcp-client { grid-template-columns: 1fr; }
6358 .mcp-section-heading { flex-wrap: wrap; }
59 .mcp-endpoint .copy { width: 100%; }
6460 .mcp-repository-list > div { grid-template-columns: 1fr; gap: 4px; }
6561 .mcp-page form > label { flex-wrap: wrap; }
6662 .mcp-page input { max-width: 100%; min-width: 0; }
dashboard/web/pages/MCP.tsx+22-25
......@@ -1,4 +1,4 @@
1import { A, useParams } from "@solidjs/router";
1import { A, useNavigate, useParams } from "@solidjs/router";
22import { parseResponse } from "hono/client";
33import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
44import { api, reason } from "../api.ts";
......@@ -7,6 +7,7 @@ import { Checkbox } from "../components/Checkbox.tsx";
77import { Copy } from "../components/Copy.tsx";
88import { showConfirmDialog } from "../components/Dialog.tsx";
99import { Loaded } from "../components/Loaded.tsx";
10import { TabBar } from "../components/TabBar.tsx";
1011import { toast } from "../components/Toast.tsx";
1112import { MCPAccess } from "./MCPAccess.tsx";
1213import type { Access, Catalog } from "../types/mcp.ts";
......@@ -20,6 +21,8 @@ const descriptions: Record<Catalog, string> = {
2021
2122export function MCP() {
2223 const params = useParams<{ catalog?: string }>();
24 const navigate = useNavigate();
25 createEffect(() => { if (!params.catalog) navigate("/mcp/settings/observability", { replace: true }); });
2326 const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get()));
2427 const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale",
2528 () => parseResponse(api.mcp.shale.$get()));
......@@ -58,25 +61,17 @@ export function MCP() {
5861 const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog);
5962 const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id);
6063 return <>
61 <header class="page-head"><div><h1>{catalog()?.name || "MCP"}</h1><p class="sub">{catalog() ? descriptions[catalog()!.id] : "Connect your AI clients and manage their access."}</p></div></header>
62 <nav class="mcp-navigation" aria-label="MCP settings">
63 <A href="/mcp" end>Overview</A>
64 <For each={data().catalogs}>{(catalog) => <A href={`/mcp/settings/${catalog.id}`}>{catalog.name}</A>}</For>
65 </nav>
66 <Show when={!params.catalog}>
67 <div class="mcp-catalogs"><For each={data().catalogs}>{(catalog) => {
68 const count = () => data().connections.filter((connection) => connection.catalog === catalog.id).length;
69 return <A href={`/mcp/settings/${catalog.id}`} class="mcp-catalog-card">
70 <div class="mcp-card-heading"><h2>{catalog.name}</h2><span aria-hidden="true">↗</span></div>
71 <p>{descriptions[catalog.id]}</p>
72 <div class="mcp-card-status"><span>{catalog.id === "shale" ? data().shale ? "Account linked" : "Account not linked" : catalog.id === "agents" ? `${data().machines.filter((machine) => machine.online).length} machines online` : "Services selected per connection"}</span>
73 <span>{count()} {count() === 1 ? "connection" : "connections"}</span></div>
74 </A>; }}</For></div>
75 <p class="muted">Open a connector to copy its installation URL or change a client’s access.</p>
76 </Show>
64 <header class="page-head"><h1>MCP</h1></header>
65 <TabBar label="MCP settings">
66 <For each={data().catalogs}>{(catalog) => <A href={`/mcp/settings/${catalog.id}`} end>{catalog.name}</A>}</For>
67 </TabBar>
7768 <Show when={params.catalog && !catalog()}><p class="empty">No connector here.</p></Show>
7869 <Show when={catalog()}>{(current) => <>
79 <section class="mcp-endpoint"><div><h2>Connect a client</h2><p>Paste this URL into your AI client’s MCP settings, then approve access.</p></div><Copy value={current().endpoint} label="connector URL" /></section>
70 <section class="mcp-endpoint">
71 <p>{descriptions[current().id]}</p>
72 <div class="mcp-section-heading"><h2>Connector URL</h2><Copy value={current().endpoint} label="connector URL" /></div>
73 <p class="muted">Add this URL to your AI client’s MCP settings, then approve access.</p>
74 </section>
8075 <Show when={current().id === "shale"}>
8176 <section class="mcp-panel"><div class="mcp-section-heading"><h2>Shale account</h2><div class="mcp-actions">
8277 <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button>
......@@ -86,30 +81,32 @@ export function MCP() {
8681 </div></div>
8782 <Loaded data={shale} what="Shale repositories" retry={retryShale}>
8883 {(account) => <Show when={account().linked} fallback={<p class="muted">Link your Shale account to connect clients.</p>}>
89 <p><strong>Repository access verified</strong><Show when={data().shale}><span class="muted"> · Linked <Ago t={data().shale!.linkedAt} /></span></Show></p>
90 <div class="mcp-repository-list"><For each={account().resources}>{(resource) => <div><span>{resource.name}</span><span class="muted">{resource.description}</span></div>}</For></div>
84 <p class="muted">Account linked<Show when={data().shale}> <Ago t={data().shale!.linkedAt} /></Show></p>
85 <details class="mcp-repositories"><summary>{account().resources.length} repositories available</summary><div class="mcp-repository-list"><For each={account().resources}>{(resource) => <div><span>{resource.name}</span><span class="muted">{resource.description}</span></div>}</For></div></details>
9186 <Show when={!account().resources.length}><p class="muted">Your account has no repositories yet.</p></Show>
9287 </Show>}
9388 </Loaded>
9489 </section>
9590 </Show>
9691 <Show when={current().id === "agents"}>
92 <section class="mcp-panel">
9793 <h2>Local machines</h2>
94 <details class="mcp-install"><summary>Install an agent</summary>
9895 <p>Install on each machine, then enter the pairing code below. The agent starts at login.</p>
9996 <h3>macOS or Linux</h3>
10097 <Copy value={`curl -fsSL ${window.location.origin}/agent/install.sh | sh`} label="macOS or Linux install command" />
10198 <h3>Windows PowerShell</h3>
10299 <Copy value={`irm ${window.location.origin}/agent/install.ps1 | iex`} label="Windows install command" />
103 <p class="muted">Use your usual terminal, without administrator privileges. Codex or Claude Code must already be installed and signed in.</p>
100 <p class="muted">Use your usual terminal, without administrator privileges. Codex or Claude Code must already be installed and signed in.</p></details>
104101 <form class="mcp-actions" onSubmit={async (event) => {
105102 event.preventDefault(); setBusy(true);
106103 try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); }
107104 catch (error) { toast(reason(error)); }
108105 finally { setBusy(false); }
109 }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label>
106 }}><label>Pairing code <input class="search" value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label>
110107 <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form>
111108 <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Run the installer on a machine to link it.</p>}>
112 <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody>
109 <table class="data"><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody>
113110 <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td>
114111 <button class="button small" onClick={async () => {
115112 try { await parseResponse(api.mcp.relay.machines[":id"].$delete({ param: { id: machine.id } })); await refetch(); }
......@@ -124,7 +121,7 @@ export function MCP() {
124121 catch (error) { toast(reason(error)); }
125122 finally { setBusy(false); }
126123 }}>
127 <label>Key name <input value={keyName()} onInput={(event) => setKeyName(event.currentTarget.value)} maxLength={100} /></label>
124 <label>Key name <input class="search" value={keyName()} onInput={(event) => setKeyName(event.currentTarget.value)} maxLength={100} /></label>
128125 <div class="mcp-resources"><For each={data().machines}>{(machine) => <Checkbox checked={keyMachines().includes(machine.id)} onChange={(checked) => setKeyMachines(checked ? [...keyMachines(), machine.id] : keyMachines().filter((id) => id !== machine.id))}>{machine.name}</Checkbox>}</For></div>
129126 <Checkbox checked={control()} onChange={setControl}>Allow session control</Checkbox>
130127 <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button>
......@@ -142,8 +139,8 @@ export function MCP() {
142139 <p>Rerun the installer to update the agent or change allowed folders. Unlink removes the machine's access immediately.</p>
143140 </details>
144141 <Show when={key()}><section class="mcp-connector"><h3>New API key</h3><p>Copy this key now. It won't be shown again.</p><Copy value={key()} label="API key" /><button class="button secondary" onClick={() => setKey("")}>Dismiss</button></section></Show>
142 </section>
145143 </Show>
146 <Show when={current().id === "observability"}><section class="mcp-panel"><h2>Service access</h2><p>Choose services when approving a client. Change its selection below at any time.</p><p class="muted">This connector grants read access to logs and traces.</p></section></Show>
147144 <section class="mcp-panel"><h2>Connected clients</h2>
148145 <Show when={connections().length} fallback={<p class="muted">No clients connected. Add the connector URL to your AI client to get started.</p>}>
149146 <div class="mcp-client-list"><For each={connections()}>{(client) => <article class="mcp-client">
dashboard/web/pages/SignIn.css+10-10
......@@ -1,10 +1,10 @@
1.sign-in-page { min-height: 100dvh; display: grid; align-content: center; justify-items: center; gap: 24px; padding: 24px; }
2.sign-in-brand { font-size: 24px; font-weight: 650; letter-spacing: -.5px; }
3.sign-in-card { width: min(100%, 380px); padding: 28px; }
4.sign-in-card h1 { margin: 0 0 24px; font-size: 22px; }
5.sign-in-card form, .sign-in-card label { display: grid; gap: 8px; }
6.sign-in-card form { gap: 18px; }
7.sign-in-card p { margin: 0; font-size: 13px; line-height: 1.5; }
8.sign-in-card label { color: var(--text-2); font-size: 13px; }
9.sign-in-card input { width: 100%; height: 38px; }
10.sign-in-card button { min-height: 38px; }
1/* The original Keycloak theme stays shared; these adapt its document to the dashboard shell. */
2body { font: 16px "Name Sans", sans-serif; }
3#root { display: contents; }
4.pf-v5-c-login__main button, .pf-v5-c-login__main input { font-family: inherit; }
5.pf-v5-c-login__main input[type="submit"] { cursor: pointer; }
6.pf-v5-c-login__main [aria-disabled="true"], .pf-v5-c-login__main :disabled { opacity: .6; cursor: wait; }
7.pf-v5-c-login__main .checkbox label { position: relative; }
8.pf-v5-c-login__main .checkbox input { display: block; position: absolute; opacity: 0; }
9.pf-v5-c-login__main .checkbox label:has(:focus-visible) { outline: 2px solid var(--primary); }
10.setup-intro { margin-bottom: 1rem; text-align: center; }
dashboard/web/pages/SignIn.tsx+95-38
......@@ -1,6 +1,7 @@
1import { createResource, createSignal, Show } from "solid-js";
1import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js";
22import { authReason, authRequest } from "../auth.ts";
3import "./SignIn.css";
3import theme from "../../../service/keycloak/theme/login/resources/css/styles.css?inline";
4import adjustments from "./SignIn.css?inline";
45
56export function SignIn() {
67 const params = new URLSearchParams(window.location.search);
......@@ -9,50 +10,106 @@ export function SignIn() {
910 const [username, setUsername] = createSignal("");
1011 const [password, setPassword] = createSignal("");
1112 const [email, setEmail] = createSignal("");
13 const [remember, setRemember] = createSignal(false);
14 const [visible, setVisible] = createSignal(false);
1215 const [busy, setBusy] = createSignal(false);
1316 const [error, setError] = createSignal("");
14 const complete = async (passkey = false) => {
17 const [notice, setNotice] = createSignal("");
18 let conditional: AbortController | undefined;
19 let disposed = false;
20 const body = () => ({ csrf: status()!.csrf, username: username(), password: password(), email: email(), setup,
21 remember: remember(), flow: params.get("flow") ?? "", next: params.get("next") ?? "/" });
22 const passkey = async (automatic = false) => {
1523 if (!status() || busy()) return;
16 setBusy(true); setError("");
24 conditional?.abort();
25 if (!automatic) { setBusy(true); setError(""); setNotice(""); }
26 const controller = new AbortController();
27 conditional = controller;
28 let selected = false;
1729 try {
18 const body = { csrf: status()!.csrf, username: username(), password: password(), email: email(), setup,
19 flow: params.get("flow") ?? "", next: params.get("next") ?? "/" };
20 let result: { next: string };
21 if (passkey) {
22 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", body);
23 const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey) });
24 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");
25 result = await authRequest("passkey/finish", { csrf: body.csrf, token, credential: credential.toJSON() });
26 } else result = await authRequest(setup ? "setup" : "password", body);
30 const request = { ...body(), username: automatic ? "" : username() };
31 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", request);
32 if (controller.signal.aborted || disposed) return;
33 const credential = await navigator.credentials.get({
34 publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey),
35 mediation: automatic ? "conditional" : "optional", signal: controller.signal,
36 });
37 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");
38 selected = true; setBusy(true);
39 const result = await authRequest<{ next: string }>("passkey/finish", { csrf: request.csrf, token, remember: remember(), credential: credential.toJSON() });
2740 window.location.assign(result.next);
2841 } catch (failure) {
29 setError(authReason(failure));
30 } finally { setBusy(false); }
42 if ((!automatic || selected) && !controller.signal.aborted) setError(authReason(failure));
43 } finally { if (!automatic || selected) setBusy(false); }
3144 };
45 createEffect(() => {
46 if (!setup && status()) void (async () => {
47 if (await PublicKeyCredential.isConditionalMediationAvailable?.() && !disposed) await passkey(true);
48 })().catch(() => {});
49 });
50 const complete = async () => {
51 if (!status() || busy()) return;
52 conditional?.abort(); setBusy(true); setError(""); setNotice("");
53 try {
54 const result = await authRequest<{ next: string }>(setup ? "setup" : "password", body());
55 window.location.assign(result.next);
56 } catch (failure) { setError(authReason(failure)); }
57 finally { setBusy(false); }
58 };
59 onMount(() => {
60 const title = document.title;
61 document.title = "sso (snow sign on)";
62 onCleanup(() => { document.title = title; });
63 });
64 onCleanup(() => { disposed = true; conditional?.abort(); });
3265 return (
33 <main class="sign-in-page">
34 <div class="sign-in-brand">snow globe</div>
35 <section class="card sign-in-card">
36 <h1>{setup ? "welcome" : "sign in"}</h1>
37 <Show when={!status.error} fallback={<><p class="error" role="alert">{authReason(status.error)}</p><button class="button" onClick={() => refetch()}>try again</button></>}>
38 <form onSubmit={(event) => { event.preventDefault(); void complete(); }}>
39 <Show when={setup} fallback={
40 <label>username<input class="search" required autocomplete="username webauthn" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} autofocus /></label>
41 }>
42 <p>Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>
43 <label>email<input class="search" type="email" required autocomplete="email" value={email()} onInput={(event) => setEmail(event.currentTarget.value)} /></label>
44 </Show>
45 <label>{setup ? "choose a password" : "password"}<input class="search" type="password" required minLength={setup ? 8 : undefined} maxLength={1024} autocomplete={setup ? "new-password" : "current-password"} value={password()} onInput={(event) => setPassword(event.currentTarget.value)} /></label>
46 <Show when={setup}><p class="muted">Use at least 8 characters. You can add a passkey next.</p></Show>
47 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
48 <button class="button primary" disabled={busy() || !status()} aria-busy={busy()}>{setup ? "create account" : "sign in"}</button>
49 <Show when={!setup}>
50 <button class="button" type="button" disabled={busy() || !status() || !username().trim()} onClick={() => complete(true)}>use a passkey</button>
51 <p class="muted">Need access or a password reset? Ask Clover for an invitation link.</p>
66 <>
67 <style>{theme + adjustments}</style>
68 <main class="pf-v5-c-login__main">
69 <div id="kc-header"><div id="kc-header-wrapper"><div class="kc-logo-text"><span>snow sign on</span></div></div></div>
70 <div class="card-pf">
71 <header><h1 id="kc-page-title">{setup ? "welcome" : "sign in to your account"}</h1></header>
72 <div id="kc-content"><div id="kc-content-wrapper">
73 <Show when={status.error || error()}><div class="alert-error pf-v5-c-alert" role="alert"><span class="pf-v5-c-alert__title">{status.error ? authReason(status.error) : error()}</span></div></Show>
74 <Show when={notice()}><div class="alert-info pf-v5-c-alert" role="status"><span class="pf-v5-c-alert__title">{notice()}</span></div></Show>
75 <Show when={!status.error} fallback={<button class="pf-v5-c-button pf-m-primary pf-m-block" onClick={() => refetch()}>try again</button>}>
76 <div id="kc-form"><div id="kc-form-wrapper">
77 <form id="kc-form-login" onSubmit={(event) => { event.preventDefault(); void complete(); }}>
78 <Show when={setup} fallback={
79 <div class="pf-v5-c-form__group">
80 <label for="username" class="pf-v5-c-form__label">username or email</label>
81 <input id="username" class="pf-v5-c-form-control" name="username" type="text" required autocomplete="username webauthn" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} autofocus />
82 </div>
83 }>
84 <p class="setup-intro">Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>
85 <div class="pf-v5-c-form__group">
86 <label for="email" class="pf-v5-c-form__label">email</label>
87 <input id="email" class="pf-v5-c-form-control" name="email" type="email" required autocomplete="email" value={email()} onInput={(event) => setEmail(event.currentTarget.value)} autofocus />
88 </div>
89 </Show>
90 <div class="pf-v5-c-form__group">
91 <label for="password" class="pf-v5-c-form__label">{setup ? "choose a password" : "password"}</label>
92 <div class="pf-v5-c-input-group">
93 <input id="password" class="pf-v5-c-form-control" name="password" type={visible() ? "text" : "password"} required minLength={setup ? 8 : undefined} maxLength={1024} autocomplete={setup ? "new-password" : "current-password"} value={password()} onInput={(event) => setPassword(event.currentTarget.value)} />
94 <button class="pf-v5-c-button pf-m-control" type="button" aria-label={visible() ? "Hide password" : "Show password"} aria-controls="password" data-password-toggle onClick={() => setVisible(!visible())}><i aria-hidden="true" /></button>
95 </div>
96 <Show when={setup}><span class="pf-v5-c-helper-text__item-text">Use at least 8 characters. You can add a passkey next.</span></Show>
97 </div>
98 <Show when={!setup}><div class="pf-v5-c-form__group">
99 <div id="kc-form-options"><div class="checkbox"><label><input id="rememberMe" name="rememberMe" type="checkbox" checked={remember()} onChange={(event) => setRemember(event.currentTarget.checked)} /> remember me</label></div></div>
100 <div><span><a href="#" onClick={(event) => { event.preventDefault(); setNotice("Ask Clover for a password reset link."); }}>forgot password?</a></span></div>
101 </div></Show>
102 <div id="kc-form-buttons" class="pf-v5-c-form__group">
103 <input class="pf-v5-c-button pf-m-primary pf-m-block" name="login" id="kc-login" type="submit" value={setup ? "create account" : "Sign In"} disabled={busy() || !status()} aria-busy={busy()} />
104 </div>
105 </form>
106 </div></div>
107 <Show when={!setup}><a id="authenticateWebAuthnButton" href="#" class="pf-v5-c-button pf-m-secondary pf-m-block" aria-disabled={busy() || !status()} onClick={(event) => { event.preventDefault(); void passkey(); }}>sign in with passkey</a></Show>
52108 </Show>
53 </form>
54 </Show>
55 </section>
56 </main>
109 </div></div>
110 </div>
111 </main>
112 <div id="credit"><a rel="noopener noreferrer" target="_blank" href="https://www.pixiv.net/en/artworks/109102745">art by 紺屋</a></div>
113 </>
57114 );
58115}
dashboard/web/styles.css-3
......@@ -316,11 +316,8 @@ button { font: inherit; color: inherit; }
316316 margin: 0 auto;
317317 --gutter: 28px;
318318 padding: 22px var(--gutter) 48px;
319 animation: rise 450ms var(--ease-out) backwards;
320319}
321320
322@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
323
324321.list-page { height: 100%; display: flex; flex-direction: column; padding-bottom: 0; }
325322.list-page > * { flex: none; }
326323.list-page .summary { display: grid; grid-template-rows: 0fr; opacity: 0; transition: grid-template-rows 350ms var(--ease-out), opacity 200ms; }
nixos/dashboard.nix+5-2
......@@ -15,14 +15,17 @@ let
1515 pname = "home-dashboard-web";
1616 version = "0.1.0";
1717 src = lib.fileset.toSource {
18 root = ../dashboard;
18 root = ../.;
1919 fileset = lib.fileset.unions [
2020 ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml
2121 ../dashboard/tsconfig.json ../dashboard/vite.config.ts
22 ../service/keycloak/theme/login/resources/css
23 ../service/keycloak/theme/login/resources/img
2224 ];
2325 };
26 sourceRoot = "source/dashboard";
2427 pnpmDeps = fetchPnpmDeps {
25 inherit (finalAttrs) pname version src;
28 inherit (finalAttrs) pname version src sourceRoot;
2629 pnpm = pnpm_10;
2730 fetcherVersion = 3;
2831 hash = "sha256-xQbdTZIAiwM7Ox+6BsTD57LEJzj10hvqYEpA03W9OGs=";
readme.md+3
......@@ -130,6 +130,9 @@ accepts `dashboard` and preserves a safety copy before restoring. Invitations re
130130expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment.
131131Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related
132132origin metadata for Snowglobe. Keycloak remains available for other services.
133The sign-in page shares the original Keycloak theme stylesheet and artwork.
134Passkeys support username-free sign-in and browser autofill; the remember-me
135checkbox chooses between a browser session cookie and a 30-day cookie.
133136
134137`tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json`
135138exports account IDs, groups, password hashes and public passkey credentials without
service/samba/service.pkl-2
......@@ -23,7 +23,6 @@ container {
2323 volumes {
2424 ["/shares/clover"] { src = site.cloverRoot; readOnly = site.cloverReadOnly }
2525 ["/shares/clover/Media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
26 ["/shares/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
2726 }
2827
2928 env {
......@@ -35,7 +34,6 @@ container {
3534 ["SAMBA_CONF_WORKGROUP"] = "PAPER_CLOVER"
3635 ["SAMBA_CONF_SERVER_STRING"] = "paper clover's nas"
3736 ["SAMBA_VOLUME_CONFIG_clover"] = "[clover]; path = /shares/clover; valid users = clo; read only = \(if (site.cloverReadOnly) "yes" else "no"); \(sharePermissions)"
38 ["SAMBA_VOLUME_CONFIG_media"] = "[media]; path = /shares/media; valid users = clo; read only = \(if (site.mediaReadOnly) "yes" else "no"); \(sharePermissions)"
3937 }
4038}
4139
service/shale/readme/issue-forms.js created+23
......@@ -0,0 +1,23 @@
1// Shale r1758 rotates the session's CSRF token while rendering each comment's
2// delete form, leaving the surrounding issue forms with the earlier token.
3(() => {
4 function normalize(root, initial = false) {
5 const forms = [...root.querySelectorAll('form[method="post" i]')];
6 const tokens = forms.flatMap(form => {
7 if (initial && form.querySelector('input[name="t"]')?.value !== 'delete') return [];
8 const input = form.querySelector('input[name="csrf_token"]');
9 return input?.value ? [input.value] : [];
10 });
11 const token = tokens.at(-1);
12 if (!token) return;
13 for (const input of document.querySelectorAll('form[method="post" i] input[name="csrf_token"]')) {
14 input.value = token;
15 }
16 }
17 function start() {
18 normalize(document, true);
19 document.body.addEventListener('htmx:afterSwap', event => normalize(event.detail.target));
20 }
21 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
22 else start();
23})();
service/shale/service.pkl+3
......@@ -26,6 +26,9 @@ container {
2626 ["/-/studio-readme/"] = "readme"
2727 }
2828 headHtml {
29 ["/*/issues/*"] = """
30 <script defer src="/-/studio-readme/issue-forms.js"></script>
31 """
2932 ["/snowbound/"] = """
3033 <script defer src="/-/studio-readme/markdown-it.min.js"></script><script defer src="/-/studio-readme/purify.min.js"></script><script defer src="/-/studio-readme/readme.js"></script>
3134 """
tools/dashboard-auth-test.py+17-3
......@@ -95,7 +95,7 @@ def main():
9595 def stop():
9696 server.terminate(); server.wait(timeout=10)
9797
98 def request(path, method='GET', body=None, cookies=None, status=200, extra=None, host=origin):
98 def request(path, method='GET', body=None, cookies=None, status=200, extra=None, host=origin, include_headers=False):
9999 headers = {'Studio-Proxy-Token': proof, 'Host': host.split('://')[1], 'X-Studio-Client-IP': '127.0.0.1'}
100100 if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/json'})
101101 if cookies: headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items())
......@@ -107,7 +107,7 @@ def main():
107107 if cookies is not None and 'set-cookie' in fields:
108108 cookie = fields['set-cookie']; assert 'Secure; HttpOnly; SameSite=Lax' in cookie and 'Domain=' not in cookie
109109 key, value = cookie.split(';', 1)[0].split('=', 1); cookies[key] = value
110 return json.loads(content) if fields.get('content-type', '').startswith('application/json') and content else fields
110 return json.loads(content) if not include_headers and fields.get('content-type', '').startswith('application/json') and content else fields
111111
112112 cookies = {}
113113 start()
......@@ -120,6 +120,10 @@ def main():
120120 request('/auth/password', 'POST', {**login, 'csrf': 'wrong'}, cookies, 403)
121121 request('/auth/password', 'POST', {**login, 'password': 'wrong'}, cookies, 401)
122122 assert request('/auth/password', 'POST', login, cookies)['next'] == '/users'
123 fields = request('/auth/password', 'POST', {**login, 'remember': False}, cookies=cookies, status=200, include_headers=True)
124 assert 'Max-Age=' not in fields['set-cookie']
125 fields = request('/auth/password', 'POST', {**login, 'remember': True}, cookies=cookies, status=200, include_headers=True)
126 assert 'Max-Age=2592000' in fields['set-cookie']
123127 assert 'admin' in request('/api/me', cookies=cookies)['sections']
124128 request('/api/users', 'POST', {}, cookies, 403, {'Origin': 'https://evil.example'})
125129 assert request('/auth/password', 'POST', {**login, 'next': '//evil.example'}, cookies)['next'] == '/'
......@@ -147,6 +151,16 @@ def main():
147151 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)
148152 tampered = assertion(begin, counter=2); tampered['response']['signature'] = b64(b'forged')
149153 request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':tampered}, other, 401)
154 for handle in [None, uuid.uuid4().bytes]:
155 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2}, other)
156 assert not begin['options']['publicKey'].get('allowCredentials')
157 credential = assertion(begin, counter=2, handle=handle or actor.encode())
158 if handle is None: credential['response'].pop('userHandle')
159 request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':credential}, other, 401)
160 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'remember':False}, other)
161 assert not begin['options']['publicKey'].get('allowCredentials')
162 request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':assertion(begin, counter=2)}, other)
163 assert request('/api/me', cookies=other)['name'] == name
150164 registration = request('/auth/passkey/register', 'POST', {'csrf': csrf}, cookies)
151165 new_id = os.urandom(32)
152166 client = json.dumps({'type': 'webauthn.create', 'challenge': registration['options']['publicKey']['challenge'], 'origin': origin, 'crossOrigin': False}).encode()
......@@ -186,7 +200,7 @@ def main():
186200 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)
187201 request('/api/me', cookies=cookies, status=401)
188202 request('/auth/file/check', cookies=file_cookies, status=401, host=file)
189 print(json.dumps({'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed'}))
203 print(json.dumps({'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'username_free_passkey': 'passed', 'remember_me': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed'}))
190204 finally:
191205 if server and server.poll() is None: stop()
192206 log.close()