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...@@ -728,7 +728,7 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
728 auth.csrf(&headers, &body)?;728 auth.csrf(&headers, &body)?;
729 if path == "/auth/password" || path == "/auth/passkey/start" {729 if path == "/auth/password" || path == "/auth/passkey/start" {
730 let name = string(&body["username"]).trim().to_lowercase();730 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") {
732 return Err(Error::new(400, "Enter your username."));732 return Err(Error::new(400, "Enter your username."));
733 }733 }
734 auth.limit(&headers, &name)?;734 auth.limit(&headers, &name)?;
...@@ -772,7 +772,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp...@@ -772,7 +772,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
772 ));772 ));
773 }773 }
774 let id = id.unwrap();774 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 }
776 let next = if !array(&user["requiredActions"]).is_empty() {779 let next = if !array(&user["requiredActions"]).is_empty() {
777 "/account".into()780 "/account".into()
778 } else {781 } else {
...@@ -782,25 +785,44 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp...@@ -782,25 +785,44 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
782 ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(),785 ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(),
783 );786 );
784 }787 }
785 if user["enabled"] != true {788 if !name.is_empty() && user["enabled"] != true {
786 return Err(Error::new(789 return Err(Error::new(
787 401,790 401,
788 "No passkey is available for that username. Try your password.",791 "No passkey is available for that username. Try your password.",
789 ));792 ));
790 }793 }
791 let id = id.unwrap();794 let keys = if let Some(id) = &id {
792 let keys = passkeys(&auth.db.lock().unwrap(), &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 };
793 if keys.is_empty() {812 if keys.is_empty() {
794 return Err(Error::new(813 return Err(Error::new(
795 401,814 401,
796 "No passkey is available for that username. Try your password.",815 "No passkey is available for that username. Try your password.",
797 ));816 ));
798 }817 }
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 }
800 let token = issue(822 let token = issue(
801 &auth.db.lock().unwrap(),823 &auth.db.lock().unwrap(),
802 "authentication",824 "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"]}),
804 300,826 300,
805 )?;827 )?;
806 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());828 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...@@ -817,6 +839,39 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
817 }839 }
818 let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone())840 let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone())
819 .map_err(|_| Error::new(400, "The browser couldn't return your passkey. Try again."))?;841 .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 };
820 let mut state = value["state"].clone();875 let mut state = value["state"].clone();
821 let mut allowed: Vec<Credential> =876 let mut allowed: Vec<Credential> =
822 serde_json::from_value(state["ast"]["credentials"].clone())?;877 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...@@ -825,9 +880,7 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
825 let mut statement = db.prepare(880 let mut statement = db.prepare(
826 "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",881 "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
827 )?;882 )?;
828 for stored in883 for stored in statement.query_map([&id], |r| r.get::<_, String>(0))? {
829 statement.query_map([string(&value["user"])], |r| r.get::<_, String>(0))?
830 {
831 let stored: Value = serde_json::from_str(&stored?)?;884 let stored: Value = serde_json::from_str(&stored?)?;
832 let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?;885 let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?;
833 if stored["backupUnknown"] == true886 if stored["backupUnknown"] == true
...@@ -861,7 +914,7 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp...@@ -861,7 +914,7 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
861 "That passkey couldn't sign in. Try again or use your password.",914 "That passkey couldn't sign in. Try again or use your password.",
862 )915 )
863 })?;916 })?;
864 let id = string(&value["user"]);917 let id = id.as_str();
865 {918 {
866 let db = auth.db.lock().unwrap();919 let db = auth.db.lock().unwrap();
867 let user = user(&db, id)?;920 let user = user(&db, id)?;
...@@ -922,7 +975,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp...@@ -922,7 +975,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
922 ));975 ));
923 }976 }
924 }977 }
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 }
926 let next =982 let next =
927 if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() {983 if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() {
928 "/account".into()984 "/account".into()
dashboard/src/main.rs+1-1
...@@ -511,7 +511,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {...@@ -511,7 +511,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
511 request.headers_mut().remove("Studio-Proxy-Token");511 request.headers_mut().remove("Studio-Proxy-Token");
512 }512 }
513 let path = request.uri().path().to_owned();513 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";
515 if app.auth.ready()515 if app.auth.ready()
516 && !mcp::public(&path)516 && !mcp::public(&path)
517 && !path.starts_with("/auth/")517 && !path.starts_with("/auth/")
dashboard/src/shale.rs+40
...@@ -9,6 +9,26 @@ use rmcp::{...@@ -9,6 +9,26 @@ use rmcp::{
9};9};
10use scraper::{Html, Selector};10use 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
12pub struct Backend {32pub struct Backend {
13 pub(crate) origin: url::Url,33 pub(crate) origin: url::Url,
14 http: reqwest::Client,34 http: reqwest::Client,
...@@ -450,6 +470,14 @@ impl ServerHandler for Shale {...@@ -450,6 +470,14 @@ impl ServerHandler for Shale {
450 }470 }
451 }471 }
452 }472 }
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 }
453 fields.insert("timezone".to_owned(), "UTC".to_owned());481 fields.insert("timezone".to_owned(), "UTC".to_owned());
454 fields.insert("tzoffset".to_owned(), "+00:00".to_owned());482 fields.insert("tzoffset".to_owned(), "+00:00".to_owned());
455 post_target483 post_target
...@@ -786,6 +814,18 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {...@@ -786,6 +814,18 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
786mod tests {814mod tests {
787 use super::*;815 use super::*;
788 #[test]816 #[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]
789 fn repository_names_cannot_change_origin_or_path_segments() {829 fn repository_names_cannot_change_origin_or_path_segments() {
790 let origin = url::Url::parse("https://shale.studio.test").unwrap();830 let origin = url::Url::parse("https://shale.studio.test").unwrap();
791 for name in [831 for name in [
dashboard/web/pages/MCP.css+21-25
...@@ -1,20 +1,19 @@...@@ -1,20 +1,19 @@
1.mcp-page { max-width: 1100px; }1/* Settings use the dashboard's controls and flat, divided sections. */
2.mcp-page h2 { font-size: 17px; margin: 0 0 12px; color: var(--text); }2.mcp-page { max-width: 1000px; }
3.mcp-page h3 { font-size: 15px; margin: 0; }3.mcp-page h2 { font-size: 14px; font-weight: 600; margin: 0 0 8px; color: var(--text); }
4.mcp-page p { color: var(--text-2); }4.mcp-page h3 { font-size: 13px; margin: 0; }
5.mcp-navigation { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--line); padding-bottom: 12px; margin: 20px 0 24px; }5.mcp-page p { color: var(--text-2); margin: 8px 0; }
6.mcp-navigation a { padding: 8px 12px; border-radius: 6px; color: var(--text-2); }6.mcp-page > .segmented { margin-bottom: 8px; }
7.mcp-navigation a:hover { background: var(--hover); }7.mcp-panel, .mcp-endpoint { padding: 20px 0; border-bottom: 1px solid var(--line); }
8.mcp-navigation a.active { background: var(--accent-wash); color: var(--accent); }8.mcp-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
9.mcp-catalogs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }9.mcp-section-heading h2 { margin: 0; flex: none; }
10.mcp-catalog-card, .mcp-panel, .mcp-endpoint { border: 1px solid var(--line); border-radius: 10px; padding: 20px; background: var(--surface); }10.mcp-endpoint > p:first-child { margin: 0 0 16px; }
11.mcp-catalog-card:hover { border-color: var(--accent); }11.mcp-endpoint .copy { max-width: 100%; min-width: 0; overflow-wrap: anywhere; white-space: normal; text-align: left; }
12.mcp-card-heading, .mcp-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }12.mcp-endpoint .muted { font-size: 12px; }
13.mcp-catalog-card p { min-height: 40px; }13.mcp-repositories, .mcp-install { margin: 12px 0; }
14.mcp-card-status { display: grid; gap: 6px; font-size: 12px; color: var(--muted); margin-top: 24px; }14.mcp-repositories summary, .mcp-install summary { cursor: pointer; color: var(--text-2); }
15.mcp-panel, .mcp-endpoint { margin: 20px 0; }15.mcp-install h3 { margin: 12px 0 6px; }
16.mcp-endpoint p { margin: 0 0 16px; }16.mcp-page .mcp-access-modes > label { padding: 6px 0; border: 0; background: none; }
17.mcp-endpoint .copy { max-width: 100%; }
18.mcp-actions { display: flex; flex-wrap: wrap; gap: 8px; }17.mcp-actions { display: flex; flex-wrap: wrap; gap: 8px; }
19.mcp-access { padding: 0; margin: 20px 0; border: 0; min-width: 0; }18.mcp-access { padding: 0; margin: 20px 0; border: 0; min-width: 0; }
20.mcp-access legend { font-weight: 600; margin-bottom: 12px; }19.mcp-access legend { font-weight: 600; margin-bottom: 12px; }
...@@ -31,16 +30,15 @@...@@ -31,16 +30,15 @@
31.mcp-client > .mcp-actions { align-self: start; }30.mcp-client > .mcp-actions { align-self: start; }
32.mcp-client p { margin: 6px 0 0; overflow-wrap: anywhere; }31.mcp-client p { margin: 6px 0 0; overflow-wrap: anywhere; }
33.mcp-edit-access { grid-column: 1 / -1; }32.mcp-edit-access { grid-column: 1 / -1; }
34.mcp-page table { width: 100%; text-align: left; border-collapse: collapse; }33.mcp-page table { margin: 16px 0; }
35.mcp-page th, .mcp-page td { padding: 12px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
36.mcp-page form { margin: 16px 0; }34.mcp-page form { margin: 16px 0; }
37.mcp-page form > label { display: flex; align-items: center; gap: 12px; }35.mcp-page form > label { display: flex; align-items: center; gap: 12px; }
38.mcp-page input { padding: 8px; }36
39.mcp-page form > button { margin-top: 12px; }37.mcp-page form:not(.mcp-actions) > button { margin-top: 12px; }
40.mcp-help { margin: 24px 0; }38.mcp-help { margin: 24px 0; }
41.mcp-help summary { cursor: pointer; }39.mcp-help summary { cursor: pointer; }
42.mcp-help li { margin: 12px 0; }40.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); }
44.mcp-authorization { min-height: 100%; display: grid; place-items: center; padding: 40px 24px; box-sizing: border-box; }42.mcp-authorization { min-height: 100%; display: grid; place-items: center; padding: 40px 24px; box-sizing: border-box; }
45.mcp-approval { width: min(100%, 600px); padding: 32px; box-sizing: border-box; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }43.mcp-approval { width: min(100%, 600px); padding: 32px; box-sizing: border-box; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
46.mcp-approval-brand { color: var(--accent); font-size: 13px; font-weight: 600; margin-bottom: 28px; }44.mcp-approval-brand { color: var(--accent); font-size: 13px; font-weight: 600; margin-bottom: 28px; }
...@@ -56,11 +54,9 @@...@@ -56,11 +54,9 @@
56.mcp-approval-actions { display: flex; justify-content: space-between; gap: 16px; padding-top: 24px; border-top: 1px solid var(--line); margin-top: 24px; }54.mcp-approval-actions { display: flex; justify-content: space-between; gap: 16px; padding-top: 24px; border-top: 1px solid var(--line); margin-top: 24px; }
57.mcp-close { background: none; border: 0; color: var(--muted); cursor: pointer; margin-top: 20px; padding: 0; text-decoration: underline; }55.mcp-close { background: none; border: 0; color: var(--muted); cursor: pointer; margin-top: 20px; padding: 0; text-decoration: underline; }
58@media (max-width: 760px) {56@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; }
62 .mcp-client { grid-template-columns: 1fr; }57 .mcp-client { grid-template-columns: 1fr; }
63 .mcp-section-heading { flex-wrap: wrap; }58 .mcp-section-heading { flex-wrap: wrap; }
59 .mcp-endpoint .copy { width: 100%; }
64 .mcp-repository-list > div { grid-template-columns: 1fr; gap: 4px; }60 .mcp-repository-list > div { grid-template-columns: 1fr; gap: 4px; }
65 .mcp-page form > label { flex-wrap: wrap; }61 .mcp-page form > label { flex-wrap: wrap; }
66 .mcp-page input { max-width: 100%; min-width: 0; }62 .mcp-page input { max-width: 100%; min-width: 0; }
dashboard/web/pages/MCP.tsx+22-25
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { A, useParams } from "@solidjs/router";1import { A, useNavigate, useParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";2import { parseResponse } from "hono/client";
3import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";3import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
4import { api, reason } from "../api.ts";4import { api, reason } from "../api.ts";
...@@ -7,6 +7,7 @@ import { Checkbox } from "../components/Checkbox.tsx";...@@ -7,6 +7,7 @@ import { Checkbox } from "../components/Checkbox.tsx";
7import { Copy } from "../components/Copy.tsx";7import { Copy } from "../components/Copy.tsx";
8import { showConfirmDialog } from "../components/Dialog.tsx";8import { showConfirmDialog } from "../components/Dialog.tsx";
9import { Loaded } from "../components/Loaded.tsx";9import { Loaded } from "../components/Loaded.tsx";
10import { TabBar } from "../components/TabBar.tsx";
10import { toast } from "../components/Toast.tsx";11import { toast } from "../components/Toast.tsx";
11import { MCPAccess } from "./MCPAccess.tsx";12import { MCPAccess } from "./MCPAccess.tsx";
12import type { Access, Catalog } from "../types/mcp.ts";13import type { Access, Catalog } from "../types/mcp.ts";
...@@ -20,6 +21,8 @@ const descriptions: Record<Catalog, string> = {...@@ -20,6 +21,8 @@ const descriptions: Record<Catalog, string> = {
2021
21export function MCP() {22export function MCP() {
22 const params = useParams<{ catalog?: string }>();23 const params = useParams<{ catalog?: string }>();
24 const navigate = useNavigate();
25 createEffect(() => { if (!params.catalog) navigate("/mcp/settings/observability", { replace: true }); });
23 const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get()));26 const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get()));
24 const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale",27 const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale",
25 () => parseResponse(api.mcp.shale.$get()));28 () => parseResponse(api.mcp.shale.$get()));
...@@ -58,25 +61,17 @@ export function MCP() {...@@ -58,25 +61,17 @@ export function MCP() {
58 const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog);61 const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog);
59 const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id);62 const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id);
60 return <>63 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>64 <header class="page-head"><h1>MCP</h1></header>
62 <nav class="mcp-navigation" aria-label="MCP settings">65 <TabBar label="MCP settings">
63 <A href="/mcp" end>Overview</A>66 <For each={data().catalogs}>{(catalog) => <A href={`/mcp/settings/${catalog.id}`} end>{catalog.name}</A>}</For>
64 <For each={data().catalogs}>{(catalog) => <A href={`/mcp/settings/${catalog.id}`}>{catalog.name}</A>}</For>67 </TabBar>
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>
77 <Show when={params.catalog && !catalog()}><p class="empty">No connector here.</p></Show>68 <Show when={params.catalog && !catalog()}><p class="empty">No connector here.</p></Show>
78 <Show when={catalog()}>{(current) => <>69 <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>
80 <Show when={current().id === "shale"}>75 <Show when={current().id === "shale"}>
81 <section class="mcp-panel"><div class="mcp-section-heading"><h2>Shale account</h2><div class="mcp-actions">76 <section class="mcp-panel"><div class="mcp-section-heading"><h2>Shale account</h2><div class="mcp-actions">
82 <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button>77 <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button>
...@@ -86,30 +81,32 @@ export function MCP() {...@@ -86,30 +81,32 @@ export function MCP() {
86 </div></div>81 </div></div>
87 <Loaded data={shale} what="Shale repositories" retry={retryShale}>82 <Loaded data={shale} what="Shale repositories" retry={retryShale}>
88 {(account) => <Show when={account().linked} fallback={<p class="muted">Link your Shale account to connect clients.</p>}>83 {(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>84 <p class="muted">Account linked<Show when={data().shale}> <Ago t={data().shale!.linkedAt} /></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>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>
91 <Show when={!account().resources.length}><p class="muted">Your account has no repositories yet.</p></Show>86 <Show when={!account().resources.length}><p class="muted">Your account has no repositories yet.</p></Show>
92 </Show>}87 </Show>}
93 </Loaded>88 </Loaded>
94 </section>89 </section>
95 </Show>90 </Show>
96 <Show when={current().id === "agents"}>91 <Show when={current().id === "agents"}>
92 <section class="mcp-panel">
97 <h2>Local machines</h2>93 <h2>Local machines</h2>
94 <details class="mcp-install"><summary>Install an agent</summary>
98 <p>Install on each machine, then enter the pairing code below. The agent starts at login.</p>95 <p>Install on each machine, then enter the pairing code below. The agent starts at login.</p>
99 <h3>macOS or Linux</h3>96 <h3>macOS or Linux</h3>
100 <Copy value={`curl -fsSL ${window.location.origin}/agent/install.sh | sh`} label="macOS or Linux install command" />97 <Copy value={`curl -fsSL ${window.location.origin}/agent/install.sh | sh`} label="macOS or Linux install command" />
101 <h3>Windows PowerShell</h3>98 <h3>Windows PowerShell</h3>
102 <Copy value={`irm ${window.location.origin}/agent/install.ps1 | iex`} label="Windows install command" />99 <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>
104 <form class="mcp-actions" onSubmit={async (event) => {101 <form class="mcp-actions" onSubmit={async (event) => {
105 event.preventDefault(); setBusy(true);102 event.preventDefault(); setBusy(true);
106 try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); }103 try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); }
107 catch (error) { toast(reason(error)); }104 catch (error) { toast(reason(error)); }
108 finally { setBusy(false); }105 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>
110 <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form>107 <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form>
111 <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Run the installer on a machine to link it.</p>}>108 <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>
113 <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td>110 <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td>
114 <button class="button small" onClick={async () => {111 <button class="button small" onClick={async () => {
115 try { await parseResponse(api.mcp.relay.machines[":id"].$delete({ param: { id: machine.id } })); await refetch(); }112 try { await parseResponse(api.mcp.relay.machines[":id"].$delete({ param: { id: machine.id } })); await refetch(); }
...@@ -124,7 +121,7 @@ export function MCP() {...@@ -124,7 +121,7 @@ export function MCP() {
124 catch (error) { toast(reason(error)); }121 catch (error) { toast(reason(error)); }
125 finally { setBusy(false); }122 finally { setBusy(false); }
126 }}>123 }}>
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>
128 <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>125 <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>
129 <Checkbox checked={control()} onChange={setControl}>Allow session control</Checkbox>126 <Checkbox checked={control()} onChange={setControl}>Allow session control</Checkbox>
130 <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button>127 <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button>
...@@ -142,8 +139,8 @@ export function MCP() {...@@ -142,8 +139,8 @@ export function MCP() {
142 <p>Rerun the installer to update the agent or change allowed folders. Unlink removes the machine's access immediately.</p>139 <p>Rerun the installer to update the agent or change allowed folders. Unlink removes the machine's access immediately.</p>
143 </details>140 </details>
144 <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>141 <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>
145 </Show>143 </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>
147 <section class="mcp-panel"><h2>Connected clients</h2>144 <section class="mcp-panel"><h2>Connected clients</h2>
148 <Show when={connections().length} fallback={<p class="muted">No clients connected. Add the connector URL to your AI client to get started.</p>}>145 <Show when={connections().length} fallback={<p class="muted">No clients connected. Add the connector URL to your AI client to get started.</p>}>
149 <div class="mcp-client-list"><For each={connections()}>{(client) => <article class="mcp-client">146 <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,10 +1,10 @@
1.sign-in-page { min-height: 100dvh; display: grid; align-content: center; justify-items: center; gap: 24px; padding: 24px; }1/* The original Keycloak theme stays shared; these adapt its document to the dashboard shell. */
2.sign-in-brand { font-size: 24px; font-weight: 650; letter-spacing: -.5px; }2body { font: 16px "Name Sans", sans-serif; }
3.sign-in-card { width: min(100%, 380px); padding: 28px; }3#root { display: contents; }
4.sign-in-card h1 { margin: 0 0 24px; font-size: 22px; }4.pf-v5-c-login__main button, .pf-v5-c-login__main input { font-family: inherit; }
5.sign-in-card form, .sign-in-card label { display: grid; gap: 8px; }5.pf-v5-c-login__main input[type="submit"] { cursor: pointer; }
6.sign-in-card form { gap: 18px; }6.pf-v5-c-login__main [aria-disabled="true"], .pf-v5-c-login__main :disabled { opacity: .6; cursor: wait; }
7.sign-in-card p { margin: 0; font-size: 13px; line-height: 1.5; }7.pf-v5-c-login__main .checkbox label { position: relative; }
8.sign-in-card label { color: var(--text-2); font-size: 13px; }8.pf-v5-c-login__main .checkbox input { display: block; position: absolute; opacity: 0; }
9.sign-in-card input { width: 100%; height: 38px; }9.pf-v5-c-login__main .checkbox label:has(:focus-visible) { outline: 2px solid var(--primary); }
10.sign-in-card button { min-height: 38px; }10.setup-intro { margin-bottom: 1rem; text-align: center; }
dashboard/web/pages/SignIn.tsx+95-38
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { createResource, createSignal, Show } from "solid-js";1import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js";
2import { authReason, authRequest } from "../auth.ts";2import { 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
5export function SignIn() {6export function SignIn() {
6 const params = new URLSearchParams(window.location.search);7 const params = new URLSearchParams(window.location.search);
...@@ -9,50 +10,106 @@ export function SignIn() {...@@ -9,50 +10,106 @@ export function SignIn() {
9 const [username, setUsername] = createSignal("");10 const [username, setUsername] = createSignal("");
10 const [password, setPassword] = createSignal("");11 const [password, setPassword] = createSignal("");
11 const [email, setEmail] = createSignal("");12 const [email, setEmail] = createSignal("");
13 const [remember, setRemember] = createSignal(false);
14 const [visible, setVisible] = createSignal(false);
12 const [busy, setBusy] = createSignal(false);15 const [busy, setBusy] = createSignal(false);
13 const [error, setError] = createSignal("");16 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) => {
15 if (!status() || busy()) return;23 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;
17 try {29 try {
18 const body = { csrf: status()!.csrf, username: username(), password: password(), email: email(), setup,30 const request = { ...body(), username: automatic ? "" : username() };
19 flow: params.get("flow") ?? "", next: params.get("next") ?? "/" };31 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", request);
20 let result: { next: string };32 if (controller.signal.aborted || disposed) return;
21 if (passkey) {33 const credential = await navigator.credentials.get({
22 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", body);34 publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey),
23 const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey) });35 mediation: automatic ? "conditional" : "optional", signal: controller.signal,
24 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");36 });
25 result = await authRequest("passkey/finish", { csrf: body.csrf, token, credential: credential.toJSON() });37 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");
26 } else result = await authRequest(setup ? "setup" : "password", body);38 selected = true; setBusy(true);
39 const result = await authRequest<{ next: string }>("passkey/finish", { csrf: request.csrf, token, remember: remember(), credential: credential.toJSON() });
27 window.location.assign(result.next);40 window.location.assign(result.next);
28 } catch (failure) {41 } catch (failure) {
29 setError(authReason(failure));42 if ((!automatic || selected) && !controller.signal.aborted) setError(authReason(failure));
30 } finally { setBusy(false); }43 } finally { if (!automatic || selected) setBusy(false); }
31 };44 };
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(); });
32 return (65 return (
33 <main class="sign-in-page">66 <>
34 <div class="sign-in-brand">snow globe</div>67 <style>{theme + adjustments}</style>
35 <section class="card sign-in-card">68 <main class="pf-v5-c-login__main">
36 <h1>{setup ? "welcome" : "sign in"}</h1>69 <div id="kc-header"><div id="kc-header-wrapper"><div class="kc-logo-text"><span>snow sign on</span></div></div></div>
37 <Show when={!status.error} fallback={<><p class="error" role="alert">{authReason(status.error)}</p><button class="button" onClick={() => refetch()}>try again</button></>}>70 <div class="card-pf">
38 <form onSubmit={(event) => { event.preventDefault(); void complete(); }}>71 <header><h1 id="kc-page-title">{setup ? "welcome" : "sign in to your account"}</h1></header>
39 <Show when={setup} fallback={72 <div id="kc-content"><div id="kc-content-wrapper">
40 <label>username<input class="search" required autocomplete="username webauthn" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} autofocus /></label>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>
41 }>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>
42 <p>Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>75 <Show when={!status.error} fallback={<button class="pf-v5-c-button pf-m-primary pf-m-block" onClick={() => refetch()}>try again</button>}>
43 <label>email<input class="search" type="email" required autocomplete="email" value={email()} onInput={(event) => setEmail(event.currentTarget.value)} /></label>76 <div id="kc-form"><div id="kc-form-wrapper">
44 </Show>77 <form id="kc-form-login" onSubmit={(event) => { event.preventDefault(); void complete(); }}>
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>78 <Show when={setup} fallback={
46 <Show when={setup}><p class="muted">Use at least 8 characters. You can add a passkey next.</p></Show>79 <div class="pf-v5-c-form__group">
47 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>80 <label for="username" class="pf-v5-c-form__label">username or email</label>
48 <button class="button primary" disabled={busy() || !status()} aria-busy={busy()}>{setup ? "create account" : "sign in"}</button>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 />
49 <Show when={!setup}>82 </div>
50 <button class="button" type="button" disabled={busy() || !status() || !username().trim()} onClick={() => complete(true)}>use a passkey</button>83 }>
51 <p class="muted">Need access or a password reset? Ask Clover for an invitation link.</p>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>
52 </Show>108 </Show>
53 </form>109 </div></div>
54 </Show>110 </div>
55 </section>111 </main>
56 </main>112 <div id="credit"><a rel="noopener noreferrer" target="_blank" href="https://www.pixiv.net/en/artworks/109102745">art by 紺屋</a></div>
113 </>
57 );114 );
58}115}
dashboard/web/styles.css-3
...@@ -316,11 +316,8 @@ button { font: inherit; color: inherit; }...@@ -316,11 +316,8 @@ button { font: inherit; color: inherit; }
316 margin: 0 auto;316 margin: 0 auto;
317 --gutter: 28px;317 --gutter: 28px;
318 padding: 22px var(--gutter) 48px;318 padding: 22px var(--gutter) 48px;
319 animation: rise 450ms var(--ease-out) backwards;
320}319}
321320
322@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
323
324.list-page { height: 100%; display: flex; flex-direction: column; padding-bottom: 0; }321.list-page { height: 100%; display: flex; flex-direction: column; padding-bottom: 0; }
325.list-page > * { flex: none; }322.list-page > * { flex: none; }
326.list-page .summary { display: grid; grid-template-rows: 0fr; opacity: 0; transition: grid-template-rows 350ms var(--ease-out), opacity 200ms; }323.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...@@ -15,14 +15,17 @@ let
15 pname = "home-dashboard-web";15 pname = "home-dashboard-web";
16 version = "0.1.0";16 version = "0.1.0";
17 src = lib.fileset.toSource {17 src = lib.fileset.toSource {
18 root = ../dashboard;18 root = ../.;
19 fileset = lib.fileset.unions [19 fileset = lib.fileset.unions [
20 ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml20 ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml
21 ../dashboard/tsconfig.json ../dashboard/vite.config.ts21 ../dashboard/tsconfig.json ../dashboard/vite.config.ts
22 ../service/keycloak/theme/login/resources/css
23 ../service/keycloak/theme/login/resources/img
22 ];24 ];
23 };25 };
26 sourceRoot = "source/dashboard";
24 pnpmDeps = fetchPnpmDeps {27 pnpmDeps = fetchPnpmDeps {
25 inherit (finalAttrs) pname version src;28 inherit (finalAttrs) pname version src sourceRoot;
26 pnpm = pnpm_10;29 pnpm = pnpm_10;
27 fetcherVersion = 3;30 fetcherVersion = 3;
28 hash = "sha256-xQbdTZIAiwM7Ox+6BsTD57LEJzj10hvqYEpA03W9OGs=";31 hash = "sha256-xQbdTZIAiwM7Ox+6BsTD57LEJzj10hvqYEpA03W9OGs=";
readme.md+3
...@@ -130,6 +130,9 @@ accepts `dashboard` and preserves a safety copy before restoring. Invitations re...@@ -130,6 +130,9 @@ accepts `dashboard` and preserves a safety copy before restoring. Invitations re
130expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment.130expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment.
131Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related131Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related
132origin metadata for Snowglobe. Keycloak remains available for other services.132origin 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
134`tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json`137`tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json`
135exports account IDs, groups, password hashes and public passkey credentials without138exports account IDs, groups, password hashes and public passkey credentials without
service/samba/service.pkl-2
...@@ -23,7 +23,6 @@ container {...@@ -23,7 +23,6 @@ container {
23 volumes {23 volumes {
24 ["/shares/clover"] { src = site.cloverRoot; readOnly = site.cloverReadOnly }24 ["/shares/clover"] { src = site.cloverRoot; readOnly = site.cloverReadOnly }
25 ["/shares/clover/Media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }25 ["/shares/clover/Media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
26 ["/shares/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly }
27 }26 }
2827
29 env {28 env {
...@@ -35,7 +34,6 @@ container {...@@ -35,7 +34,6 @@ container {
35 ["SAMBA_CONF_WORKGROUP"] = "PAPER_CLOVER"34 ["SAMBA_CONF_WORKGROUP"] = "PAPER_CLOVER"
36 ["SAMBA_CONF_SERVER_STRING"] = "paper clover's nas"35 ["SAMBA_CONF_SERVER_STRING"] = "paper clover's nas"
37 ["SAMBA_VOLUME_CONFIG_clover"] = "[clover]; path = /shares/clover; valid users = clo; read only = \(if (site.cloverReadOnly) "yes" else "no"); \(sharePermissions)"36 ["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)"
39 }37 }
40}38}
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 {...@@ -26,6 +26,9 @@ container {
26 ["/-/studio-readme/"] = "readme"26 ["/-/studio-readme/"] = "readme"
27 }27 }
28 headHtml {28 headHtml {
29 ["/*/issues/*"] = """
30 <script defer src="/-/studio-readme/issue-forms.js"></script>
31 """
29 ["/snowbound/"] = """32 ["/snowbound/"] = """
30 <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>33 <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>
31 """34 """
tools/dashboard-auth-test.py+17-3
...@@ -95,7 +95,7 @@ def main():...@@ -95,7 +95,7 @@ def main():
95 def stop():95 def stop():
96 server.terminate(); server.wait(timeout=10)96 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):
99 headers = {'Studio-Proxy-Token': proof, 'Host': host.split('://')[1], 'X-Studio-Client-IP': '127.0.0.1'}99 headers = {'Studio-Proxy-Token': proof, 'Host': host.split('://')[1], 'X-Studio-Client-IP': '127.0.0.1'}
100 if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/json'})100 if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/json'})
101 if cookies: headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items())101 if cookies: headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items())
...@@ -107,7 +107,7 @@ def main():...@@ -107,7 +107,7 @@ def main():
107 if cookies is not None and 'set-cookie' in fields:107 if cookies is not None and 'set-cookie' in fields:
108 cookie = fields['set-cookie']; assert 'Secure; HttpOnly; SameSite=Lax' in cookie and 'Domain=' not in cookie108 cookie = fields['set-cookie']; assert 'Secure; HttpOnly; SameSite=Lax' in cookie and 'Domain=' not in cookie
109 key, value = cookie.split(';', 1)[0].split('=', 1); cookies[key] = value109 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 fields110 return json.loads(content) if not include_headers and fields.get('content-type', '').startswith('application/json') and content else fields
111111
112 cookies = {}112 cookies = {}
113 start()113 start()
...@@ -120,6 +120,10 @@ def main():...@@ -120,6 +120,10 @@ def main():
120 request('/auth/password', 'POST', {**login, 'csrf': 'wrong'}, cookies, 403)120 request('/auth/password', 'POST', {**login, 'csrf': 'wrong'}, cookies, 403)
121 request('/auth/password', 'POST', {**login, 'password': 'wrong'}, cookies, 401)121 request('/auth/password', 'POST', {**login, 'password': 'wrong'}, cookies, 401)
122 assert request('/auth/password', 'POST', login, cookies)['next'] == '/users'122 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']
123 assert 'admin' in request('/api/me', cookies=cookies)['sections']127 assert 'admin' in request('/api/me', cookies=cookies)['sections']
124 request('/api/users', 'POST', {}, cookies, 403, {'Origin': 'https://evil.example'})128 request('/api/users', 'POST', {}, cookies, 403, {'Origin': 'https://evil.example'})
125 assert request('/auth/password', 'POST', {**login, 'next': '//evil.example'}, cookies)['next'] == '/'129 assert request('/auth/password', 'POST', {**login, 'next': '//evil.example'}, cookies)['next'] == '/'
...@@ -147,6 +151,16 @@ def main():...@@ -147,6 +151,16 @@ def main():
147 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)151 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)
148 tampered = assertion(begin, counter=2); tampered['response']['signature'] = b64(b'forged')152 tampered = assertion(begin, counter=2); tampered['response']['signature'] = b64(b'forged')
149 request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':tampered}, other, 401)153 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
150 registration = request('/auth/passkey/register', 'POST', {'csrf': csrf}, cookies)164 registration = request('/auth/passkey/register', 'POST', {'csrf': csrf}, cookies)
151 new_id = os.urandom(32)165 new_id = os.urandom(32)
152 client = json.dumps({'type': 'webauthn.create', 'challenge': registration['options']['publicKey']['challenge'], 'origin': origin, 'crossOrigin': False}).encode()166 client = json.dumps({'type': 'webauthn.create', 'challenge': registration['options']['publicKey']['challenge'], 'origin': origin, 'crossOrigin': False}).encode()
...@@ -186,7 +200,7 @@ def main():...@@ -186,7 +200,7 @@ def main():
186 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)200 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)
187 request('/api/me', cookies=cookies, status=401)201 request('/api/me', cookies=cookies, status=401)
188 request('/auth/file/check', cookies=file_cookies, status=401, host=file)202 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'}))
190 finally:204 finally:
191 if server and server.poll() is None: stop()205 if server and server.poll() is None: stop()
192 log.close()206 log.close()