| author | |
| committer | |
| log | fb0278c62cbe94f2d7874ec12d7a8a33a84b301d |
| tree | b9ea83713bd981618a338ef5e9c8f6323dc5d826 |
| parent | a7246389ae8115bab036e4edf5f5240d06901251 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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 | 728 | auth.csrf(&headers, &body)?; |
| 729 | 729 | if path == "/auth/password" || path == "/auth/passkey/start" { |
| 730 | 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 | 732 | return Err(Error::new(400, "Enter your username.")); |
| 733 | 733 | } |
| 734 | 734 | auth.limit(&headers, &name)?; |
| ... | ... | @@ -772,7 +772,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp |
| 772 | 772 | )); |
| 773 | 773 | } |
| 774 | 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 | 779 | let next = if !array(&user["requiredActions"]).is_empty() { |
| 777 | 780 | "/account".into() |
| 778 | 781 | } else { |
| ... | ... | @@ -782,25 +785,44 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp |
| 782 | 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 | 789 | return Err(Error::new( |
| 787 | 790 | 401, |
| 788 | 791 | "No passkey is available for that username. Try your password.", |
| 789 | 792 | )); |
| 790 | 793 | } |
| 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 | }; | |
| 793 | 812 | if keys.is_empty() { |
| 794 | 813 | return Err(Error::new( |
| 795 | 814 | 401, |
| 796 | 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 | 822 | let token = issue( |
| 801 | 823 | &auth.db.lock().unwrap(), |
| 802 | 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 | 826 | 300, |
| 805 | 827 | )?; |
| 806 | 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 | 839 | } |
| 818 | 840 | let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone()) |
| 819 | 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 | 875 | let mut state = value["state"].clone(); |
| 821 | 876 | let mut allowed: Vec<Credential> = |
| 822 | 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 | 880 | let mut statement = db.prepare( |
| 826 | 881 | "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'", |
| 827 | 882 | )?; |
| 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))? { | |
| 831 | 884 | let stored: Value = serde_json::from_str(&stored?)?; |
| 832 | 885 | let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?; |
| 833 | 886 | if stored["backupUnknown"] == true |
| ... | ... | @@ -861,7 +914,7 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp |
| 861 | 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 | 919 | let db = auth.db.lock().unwrap(); |
| 867 | 920 | let user = user(&db, id)?; |
| ... | ... | @@ -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 | 982 | let next = |
| 927 | 983 | if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() { |
| 928 | 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 | 511 | request.headers_mut().remove("Studio-Proxy-Token"); |
| 512 | 512 | } |
| 513 | 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 | 515 | if app.auth.ready() |
| 516 | 516 | && !mcp::public(&path) |
| 517 | 517 | && !path.starts_with("/auth/") |
dashboard/src/shale.rs+40| ... | ... | @@ -9,6 +9,26 @@ use rmcp::{ |
| 9 | 9 | }; |
| 10 | 10 | use scraper::{Html, Selector}; |
| 11 | 11 | |
| 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. | |
| 14 | fn 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 | ||
| 12 | 32 | pub struct Backend { |
| 13 | 33 | pub(crate) origin: url::Url, |
| 14 | 34 | http: reqwest::Client, |
| ... | ... | @@ -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 | 481 | fields.insert("timezone".to_owned(), "UTC".to_owned()); |
| 454 | 482 | fields.insert("tzoffset".to_owned(), "+00:00".to_owned()); |
| 455 | 483 | post_target |
| ... | ... | @@ -786,6 +814,18 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response { |
| 786 | 814 | mod tests { |
| 787 | 815 | use super::*; |
| 788 | 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 | 829 | fn repository_names_cannot_change_origin_or_path_segments() { |
| 790 | 830 | let origin = url::Url::parse("https://shale.studio.test").unwrap(); |
| 791 | 831 | 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; } | |
| 18 | 17 | .mcp-actions { display: flex; flex-wrap: wrap; gap: 8px; } |
| 19 | 18 | .mcp-access { padding: 0; margin: 20px 0; border: 0; min-width: 0; } |
| 20 | 19 | .mcp-access legend { font-weight: 600; margin-bottom: 12px; } |
| ... | ... | @@ -31,16 +30,15 @@ |
| 31 | 30 | .mcp-client > .mcp-actions { align-self: start; } |
| 32 | 31 | .mcp-client p { margin: 6px 0 0; overflow-wrap: anywhere; } |
| 33 | 32 | .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; } | |
| 36 | 34 | .mcp-page form { margin: 16px 0; } |
| 37 | 35 | .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; } | |
| 40 | 38 | .mcp-help { margin: 24px 0; } |
| 41 | 39 | .mcp-help summary { cursor: pointer; } |
| 42 | 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 | 42 | .mcp-authorization { min-height: 100%; display: grid; place-items: center; padding: 40px 24px; box-sizing: border-box; } |
| 45 | 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 | 44 | .mcp-approval-brand { color: var(--accent); font-size: 13px; font-weight: 600; margin-bottom: 28px; } |
| ... | ... | @@ -56,11 +54,9 @@ |
| 56 | 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 | 55 | .mcp-close { background: none; border: 0; color: var(--muted); cursor: pointer; margin-top: 20px; padding: 0; text-decoration: underline; } |
| 58 | 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 | 57 | .mcp-client { grid-template-columns: 1fr; } |
| 63 | 58 | .mcp-section-heading { flex-wrap: wrap; } |
| 59 | .mcp-endpoint .copy { width: 100%; } | |
| 64 | 60 | .mcp-repository-list > div { grid-template-columns: 1fr; gap: 4px; } |
| 65 | 61 | .mcp-page form > label { flex-wrap: wrap; } |
| 66 | 62 | .mcp-page input { max-width: 100%; min-width: 0; } |
dashboard/web/pages/MCP.tsx+22-25| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { A, useParams } from "@solidjs/router"; | |
| 1 | import { A, useNavigate, useParams } from "@solidjs/router"; | |
| 2 | 2 | import { parseResponse } from "hono/client"; |
| 3 | 3 | import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; |
| 4 | 4 | import { api, reason } from "../api.ts"; |
| ... | ... | @@ -7,6 +7,7 @@ import { Checkbox } from "../components/Checkbox.tsx"; |
| 7 | 7 | import { Copy } from "../components/Copy.tsx"; |
| 8 | 8 | import { showConfirmDialog } from "../components/Dialog.tsx"; |
| 9 | 9 | import { Loaded } from "../components/Loaded.tsx"; |
| 10 | import { TabBar } from "../components/TabBar.tsx"; | |
| 10 | 11 | import { toast } from "../components/Toast.tsx"; |
| 11 | 12 | import { MCPAccess } from "./MCPAccess.tsx"; |
| 12 | 13 | import type { Access, Catalog } from "../types/mcp.ts"; |
| ... | ... | @@ -20,6 +21,8 @@ const descriptions: Record<Catalog, string> = { |
| 20 | 21 | |
| 21 | 22 | export function MCP() { |
| 22 | 23 | const params = useParams<{ catalog?: string }>(); |
| 24 | const navigate = useNavigate(); | |
| 25 | createEffect(() => { if (!params.catalog) navigate("/mcp/settings/observability", { replace: true }); }); | |
| 23 | 26 | const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get())); |
| 24 | 27 | const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale", |
| 25 | 28 | () => parseResponse(api.mcp.shale.$get())); |
| ... | ... | @@ -58,25 +61,17 @@ export function MCP() { |
| 58 | 61 | const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog); |
| 59 | 62 | const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id); |
| 60 | 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> | |
| 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> | |
| 77 | 68 | <Show when={params.catalog && !catalog()}><p class="empty">No connector here.</p></Show> |
| 78 | 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 | 75 | <Show when={current().id === "shale"}> |
| 81 | 76 | <section class="mcp-panel"><div class="mcp-section-heading"><h2>Shale account</h2><div class="mcp-actions"> |
| 82 | 77 | <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button> |
| ... | ... | @@ -86,30 +81,32 @@ export function MCP() { |
| 86 | 81 | </div></div> |
| 87 | 82 | <Loaded data={shale} what="Shale repositories" retry={retryShale}> |
| 88 | 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> | |
| 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> | |
| 91 | 86 | <Show when={!account().resources.length}><p class="muted">Your account has no repositories yet.</p></Show> |
| 92 | 87 | </Show>} |
| 93 | 88 | </Loaded> |
| 94 | 89 | </section> |
| 95 | 90 | </Show> |
| 96 | 91 | <Show when={current().id === "agents"}> |
| 92 | <section class="mcp-panel"> | |
| 97 | 93 | <h2>Local machines</h2> |
| 94 | <details class="mcp-install"><summary>Install an agent</summary> | |
| 98 | 95 | <p>Install on each machine, then enter the pairing code below. The agent starts at login.</p> |
| 99 | 96 | <h3>macOS or Linux</h3> |
| 100 | 97 | <Copy value={`curl -fsSL ${window.location.origin}/agent/install.sh | sh`} label="macOS or Linux install command" /> |
| 101 | 98 | <h3>Windows PowerShell</h3> |
| 102 | 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 | 101 | <form class="mcp-actions" onSubmit={async (event) => { |
| 105 | 102 | event.preventDefault(); setBusy(true); |
| 106 | 103 | try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); } |
| 107 | 104 | catch (error) { toast(reason(error)); } |
| 108 | 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 | 107 | <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form> |
| 111 | 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 | 110 | <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td> |
| 114 | 111 | <button class="button small" onClick={async () => { |
| 115 | 112 | try { await parseResponse(api.mcp.relay.machines[":id"].$delete({ param: { id: machine.id } })); await refetch(); } |
| ... | ... | @@ -124,7 +121,7 @@ export function MCP() { |
| 124 | 121 | catch (error) { toast(reason(error)); } |
| 125 | 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 | 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 | 126 | <Checkbox checked={control()} onChange={setControl}>Allow session control</Checkbox> |
| 130 | 127 | <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button> |
| ... | ... | @@ -142,8 +139,8 @@ export function MCP() { |
| 142 | 139 | <p>Rerun the installer to update the agent or change allowed folders. Unlink removes the machine's access immediately.</p> |
| 143 | 140 | </details> |
| 144 | 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 | 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 | 144 | <section class="mcp-panel"><h2>Connected clients</h2> |
| 148 | 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 | 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 | .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. */ | |
| 2 | body { 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 @@ |
| 1 | import { createResource, createSignal, Show } from "solid-js"; | |
| 1 | import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"; | |
| 2 | 2 | import { authReason, authRequest } from "../auth.ts"; |
| 3 | import "./SignIn.css"; | |
| 3 | import theme from "../../../service/keycloak/theme/login/resources/css/styles.css?inline"; | |
| 4 | import adjustments from "./SignIn.css?inline"; | |
| 4 | 5 | |
| 5 | 6 | export function SignIn() { |
| 6 | 7 | const params = new URLSearchParams(window.location.search); |
| ... | ... | @@ -9,50 +10,106 @@ export function SignIn() { |
| 9 | 10 | const [username, setUsername] = createSignal(""); |
| 10 | 11 | const [password, setPassword] = createSignal(""); |
| 11 | 12 | const [email, setEmail] = createSignal(""); |
| 13 | const [remember, setRemember] = createSignal(false); | |
| 14 | const [visible, setVisible] = createSignal(false); | |
| 12 | 15 | const [busy, setBusy] = createSignal(false); |
| 13 | 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 | 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 | 29 | 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() }); | |
| 27 | 40 | window.location.assign(result.next); |
| 28 | 41 | } 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); } | |
| 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 | 65 | 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> | |
| 52 | 108 | </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 | </> | |
| 57 | 114 | ); |
| 58 | 115 | } |
dashboard/web/styles.css-3| ... | ... | @@ -316,11 +316,8 @@ button { font: inherit; color: inherit; } |
| 316 | 316 | margin: 0 auto; |
| 317 | 317 | --gutter: 28px; |
| 318 | 318 | padding: 22px var(--gutter) 48px; |
| 319 | animation: rise 450ms var(--ease-out) backwards; | |
| 320 | 319 | } |
| 321 | 320 | |
| 322 | @keyframes rise { from { opacity: 0; transform: translateY(8px); } } | |
| 323 | ||
| 324 | 321 | .list-page { height: 100%; display: flex; flex-direction: column; padding-bottom: 0; } |
| 325 | 322 | .list-page > * { flex: none; } |
| 326 | 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 | 15 | pname = "home-dashboard-web"; |
| 16 | 16 | version = "0.1.0"; |
| 17 | 17 | src = lib.fileset.toSource { |
| 18 | root = ../dashboard; | |
| 18 | root = ../.; | |
| 19 | 19 | fileset = lib.fileset.unions [ |
| 20 | 20 | ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml |
| 21 | 21 | ../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 | 27 | pnpmDeps = fetchPnpmDeps { |
| 25 | inherit (finalAttrs) pname version src; | |
| 28 | inherit (finalAttrs) pname version src sourceRoot; | |
| 26 | 29 | pnpm = pnpm_10; |
| 27 | 30 | fetcherVersion = 3; |
| 28 | 31 | hash = "sha256-xQbdTZIAiwM7Ox+6BsTD57LEJzj10hvqYEpA03W9OGs="; |
readme.md+3| ... | ... | @@ -130,6 +130,9 @@ accepts `dashboard` and preserves a safety copy before restoring. Invitations re |
| 130 | 130 | expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment. |
| 131 | 131 | Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related |
| 132 | 132 | origin metadata for Snowglobe. Keycloak remains available for other services. |
| 133 | The sign-in page shares the original Keycloak theme stylesheet and artwork. | |
| 134 | Passkeys support username-free sign-in and browser autofill; the remember-me | |
| 135 | checkbox chooses between a browser session cookie and a 30-day cookie. | |
| 133 | 136 | |
| 134 | 137 | `tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json` |
| 135 | 138 | exports account IDs, groups, password hashes and public passkey credentials without |
service/samba/service.pkl-2| ... | ... | @@ -23,7 +23,6 @@ container { |
| 23 | 23 | volumes { |
| 24 | 24 | ["/shares/clover"] { src = site.cloverRoot; readOnly = site.cloverReadOnly } |
| 25 | 25 | ["/shares/clover/Media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly } |
| 26 | ["/shares/media"] { src = site.mediaRoot; readOnly = site.mediaReadOnly } | |
| 27 | 26 | } |
| 28 | 27 | |
| 29 | 28 | env { |
| ... | ... | @@ -35,7 +34,6 @@ container { |
| 35 | 34 | ["SAMBA_CONF_WORKGROUP"] = "PAPER_CLOVER" |
| 36 | 35 | ["SAMBA_CONF_SERVER_STRING"] = "paper clover's nas" |
| 37 | 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 | } |
| 41 | 39 |
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 | 26 | ["/-/studio-readme/"] = "readme" |
| 27 | 27 | } |
| 28 | 28 | headHtml { |
| 29 | ["/*/issues/*"] = """ | |
| 30 | <script defer src="/-/studio-readme/issue-forms.js"></script> | |
| 31 | """ | |
| 29 | 32 | ["/snowbound/"] = """ |
| 30 | 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 | 95 | def stop(): |
| 96 | 96 | server.terminate(); server.wait(timeout=10) |
| 97 | 97 | |
| 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 | 99 | headers = {'Studio-Proxy-Token': proof, 'Host': host.split('://')[1], 'X-Studio-Client-IP': '127.0.0.1'} |
| 100 | 100 | if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/json'}) |
| 101 | 101 | if cookies: headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items()) |
| ... | ... | @@ -107,7 +107,7 @@ def main(): |
| 107 | 107 | if cookies is not None and 'set-cookie' in fields: |
| 108 | 108 | cookie = fields['set-cookie']; assert 'Secure; HttpOnly; SameSite=Lax' in cookie and 'Domain=' not in cookie |
| 109 | 109 | 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 | |
| 111 | 111 | |
| 112 | 112 | cookies = {} |
| 113 | 113 | start() |
| ... | ... | @@ -120,6 +120,10 @@ def main(): |
| 120 | 120 | request('/auth/password', 'POST', {**login, 'csrf': 'wrong'}, cookies, 403) |
| 121 | 121 | request('/auth/password', 'POST', {**login, 'password': 'wrong'}, cookies, 401) |
| 122 | 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 | 127 | assert 'admin' in request('/api/me', cookies=cookies)['sections'] |
| 124 | 128 | request('/api/users', 'POST', {}, cookies, 403, {'Origin': 'https://evil.example'}) |
| 125 | 129 | assert request('/auth/password', 'POST', {**login, 'next': '//evil.example'}, cookies)['next'] == '/' |
| ... | ... | @@ -147,6 +151,16 @@ def main(): |
| 147 | 151 | begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other) |
| 148 | 152 | tampered = assertion(begin, counter=2); tampered['response']['signature'] = b64(b'forged') |
| 149 | 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 | 164 | registration = request('/auth/passkey/register', 'POST', {'csrf': csrf}, cookies) |
| 151 | 165 | new_id = os.urandom(32) |
| 152 | 166 | client = json.dumps({'type': 'webauthn.create', 'challenge': registration['options']['publicKey']['challenge'], 'origin': origin, 'crossOrigin': False}).encode() |
| ... | ... | @@ -186,7 +200,7 @@ def main(): |
| 186 | 200 | request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204) |
| 187 | 201 | request('/api/me', cookies=cookies, status=401) |
| 188 | 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 | 204 | finally: |
| 191 | 205 | if server and server.poll() is None: stop() |
| 192 | 206 | log.close() |