
"));
+ for (model, short) in [("qwen3.8-27b", "Qwen"), ("big-pickle-2", "Big Pickle"), ("claude-new-variant-5-5", "New Variant")] {
+ let mut profile = ai_profile(Some(model));
+ profile.owner = Some(("clover".into(), None));
+ let profiles = HashMap::from([("ai-model".into(), profile)]);
+ let html = "
";
+ let output = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap();
+ let document = Html::parse_document(&output);
+ assert_eq!(document.select(&Selector::parse("td:nth-child(4)").unwrap()).next().unwrap().text().collect::
().trim(), format!("{short} (~clover)"));
+ }
+ }
+
#[test]
fn rewrites_only_known_local_account_links_and_escapes_provider_names() {
let origin = url::Url::parse("https://shale.paperclover.net/").unwrap();
@@ -282,6 +631,7 @@ mod tests {
name: "<&\"clover".into(),
url: Some("https://github.com/clover".into()),
icon: include_str!("../web/sso/github.svg"),
+ owner: None,
picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()),
},
),
@@ -291,12 +641,13 @@ mod tests {
name: "Astheno user".into(),
url: None,
icon: include_str!("astheno.svg"),
+ owner: None,
picture: None,
},
),
]);
let html = r#"
~guest-github-123
guestunknownexternalrepo~guest-github-123
"#;
- let rewritten = rewrite(html, &origin, &profiles).unwrap();
+ let rewritten = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap();
let page = Html::parse_document(&rewritten);
let links: Vec<_> = page.select(&Selector::parse("body > a").unwrap()).collect();
assert_eq!(links[0].attr("target"), Some("_blank"));
diff --git a/dashboard/src/users.rs b/dashboard/src/users.rs
index e0a76c3ddd65cca667ad4534af3a0f225bfce856..34fb405396ddfaaee8182132a7f7310212407238 100644
--- a/dashboard/src/users.rs
+++ b/dashboard/src/users.rs
@@ -35,6 +35,9 @@ fn profile(body: &Value, full: bool) -> Result {
let value = match key {
"username" => {
let v = string(value).trim().to_lowercase();
+ if v.starts_with("ai-") {
+ return Err(Error::new(400, "Usernames beginning with ai- are reserved for model accounts."));
+ }
if v.len() > 254 || !username_pattern.is_match(&v) {
return Err(Error::new(
400,
@@ -155,7 +158,7 @@ pub async fn route(
for id in &users {
uuid(id)?;
let user = auth::user(&transaction, id)?;
- if guest::is_guest(&user) {
+ if guest::is_guest(&user) || user["kind"] == "ai" {
return Err(Error::new(
400,
"Guests can use Shale only. Select regular accounts to edit groups.",
@@ -196,7 +199,7 @@ pub async fn route(
}
if parts.is_empty() && method == Method::GET {
let db = app.auth.db.lock().unwrap();
- let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?;
+ let mut statement = db.prepare("SELECT id FROM users WHERE coalesce(json_extract(profile,'$.kind'),'')!='ai' ORDER BY username")?;
let ids = statement
.query_map([], |r| r.get::<_, String>(0))?
.collect::, _>>()?;
@@ -274,6 +277,9 @@ pub async fn route(
.first()
.ok_or_else(|| Error::new(404, "No user here."))?;
uuid(id)?;
+ if auth::user(&app.auth.db.lock().unwrap(), id)?["kind"] == "ai" {
+ return Err(Error::new(404, "No user here."));
+ }
if *parts == [*id, "setup-link"] && method == Method::POST {
return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response());
}
@@ -355,6 +361,19 @@ pub async fn route(
}
Value::Null
}
+ [_, "commit-identity"] if method == Method::GET => identity::account(&transaction, id)?,
+ [_, "commit-emails", email] if method == Method::PUT => {
+ let email = identity::email(&json!(percent_encoding::percent_decode_str(email).decode_utf8().map_err(|_| Error::new(400,"Enter a full email address."))?))?;
+ let exists: bool = transaction.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE user_id=? AND email=?)",sql![id,email],|r|r.get(0))?;
+ if !exists { return Err(Error::new(404, "This account has not added that email.")); }
+ identity::add_email(&transaction,id,&json!(email),true)?;
+ Value::Null
+ }
+ [_, "signing-keys", fingerprint] if method == Method::PUT => {
+ let fingerprint = percent_encoding::percent_decode_str(fingerprint).decode_utf8().map_err(|_| Error::new(400,"Choose a signing key."))?;
+ identity::confirm_key(&transaction,id,&fingerprint)?;
+ Value::Null
+ }
[_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?,
[_, "logout"] if method == Method::POST => {
transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
@@ -457,6 +476,7 @@ pub async fn account(
let id = string(&user["id"]).to_owned();
let value = match parts {
[] if method == Method::GET => {
+ user["commitIdentity"] = identity::account(&app.auth.db.lock().unwrap(), &id)?;
user["picture"] = user["attributes"]["picture"][0].clone();
user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?;
user.as_object_mut().unwrap().remove("attributes");
@@ -485,6 +505,56 @@ pub async fn account(
auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
Value::Null
}
+ ["commit-emails"] if method == Method::POST => {
+ app.auth.recent(request.headers())?;
+ let body: Value = serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?;
+ let mut db = app.auth.db.lock().unwrap();
+ let tx = db.transaction()?;
+ identity::add_email(&tx, &id, &body["email"], false)?;
+ tx.commit()?;
+ Value::Null
+ }
+ ["commit-emails", email] if method == Method::PUT || method == Method::DELETE => {
+ app.auth.recent(request.headers())?;
+ let email = identity::email(&json!(percent_encoding::percent_decode_str(email).decode_utf8().map_err(|_| Error::new(400,"Enter a full email address."))?))?;
+ let mut db = app.auth.db.lock().unwrap();
+ let tx = db.transaction()?;
+ if method == Method::PUT {
+ if !array(&user["groups"]).iter().any(|group| group["name"] == "infra-admin") {
+ return Err(Error::new(403, "Ask an admin to confirm ownership of this email."));
+ }
+ let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE user_id=? AND email=?)",sql![id,email],|r|r.get(0))?;
+ if !exists { return Err(Error::new(404, "Add this email before confirming ownership.")); }
+ identity::add_email(&tx, &id, &json!(email), true)?;
+ } else {
+ tx.execute("DELETE FROM commit_emails WHERE user_id=? AND email=?",sql![id,email])?;
+ }
+ tx.commit()?;
+ Value::Null
+ }
+ ["signing-keys"] if method == Method::POST => {
+ app.auth.recent(request.headers())?;
+ let body: Value = serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 32768).await?)?;
+ let mut db = app.auth.db.lock().unwrap();
+ let tx = db.transaction()?;
+ identity::add_key(&tx, &id, &body, false)?;
+ tx.commit()?;
+ Value::Null
+ }
+ ["signing-keys", fingerprint] if method == Method::PUT || method == Method::DELETE => {
+ let fingerprint = percent_encoding::percent_decode_str(fingerprint).decode_utf8().map_err(|_| Error::new(400,"Choose a signing key."))?;
+ app.auth.recent(request.headers())?;
+ let mut db = app.auth.db.lock().unwrap();
+ let tx = db.transaction()?;
+ if method == Method::PUT {
+ if !array(&user["groups"]).iter().any(|group| group["name"] == "infra-admin") {
+ return Err(Error::new(403, "Ask an admin to confirm ownership of this signing key."));
+ }
+ identity::confirm_key(&tx,&id,&fingerprint)?;
+ } else { tx.execute("DELETE FROM signing_keys WHERE user_id=? AND fingerprint=?",sql![id,fingerprint.as_ref()])?; }
+ tx.commit()?;
+ Value::Null
+ }
["credentials", credential] if method == Method::DELETE => {
app.auth.recent(request.headers())?;
let mut db = app.auth.db.lock().unwrap();
diff --git a/dashboard/web/api.contract.ts b/dashboard/web/api.contract.ts
index d5a026d243f5204d4bdd35d2fc5d720572a14aec..935e3a5bf6be34f59cc95bc83abb944637d1c087 100644
--- a/dashboard/web/api.contract.ts
+++ b/dashboard/web/api.contract.ts
@@ -229,6 +229,15 @@ export type Api = Hono<{}, {
$put: Endpoint;
$delete: Endpoint;
};
+ "/users/:id/commit-identity": {
+ $get: Endpoint;
+ };
+ "/users/:id/commit-emails/:email": {
+ $put: Endpoint;
+ };
+ "/users/:id/signing-keys/:fingerprint": {
+ $put: Endpoint;
+ };
"/users/:id/credentials": {
$get: Endpoint;
};
@@ -302,9 +311,23 @@ export type Api = Hono<{}, {
$post: Endpoint;
};
"/account": {
- $get: Endpoint;
+ $get: Endpoint;
$patch: Endpoint;
};
+ "/account/commit-emails": {
+ $post: Endpoint;
+ };
+ "/account/commit-emails/:email": {
+ $put: Endpoint;
+ $delete: Endpoint;
+ };
+ "/account/signing-keys": {
+ $post: Endpoint;
+ };
+ "/account/signing-keys/:fingerprint": {
+ $put: Endpoint;
+ $delete: Endpoint;
+ };
"/account/picture": {
$put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>;
$delete: Endpoint;
diff --git a/dashboard/web/pages/Account.css b/dashboard/web/pages/Account.css
index 736fc2af07445cfc86bd6f93bd2779c49dbe170f..6122c69b57501e2326a162534125bbb7f4d3abe8 100644
--- a/dashboard/web/pages/Account.css
+++ b/dashboard/web/pages/Account.css
@@ -22,3 +22,15 @@
.credentials > .button { justify-self: end; }
.credentials .passkey { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.picture-row .button:has(:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
+
+.commit-identity .credentials { grid-template-columns: max-content minmax(0, 1fr) auto; }
+.commit-key code { display: block; overflow-wrap: anywhere; font-size: 11px; }
+.commit-email { overflow-wrap: anywhere; }
+.commit-identity .actions { display: flex; flex-wrap: wrap; gap: 4px; justify-content: end; }
+.commit-identity { container-type: inline-size; }
+@container (max-width: 360px) {
+ .commit-identity .credentials { grid-template-columns: minmax(0, 1fr) auto; }
+ .commit-identity .credentials > span:empty { display: none; }
+ .commit-identity .commit-email, .commit-identity .commit-key { grid-column: 1 / -1; }
+ .commit-identity .actions { grid-column: 2; }
+}
diff --git a/dashboard/web/pages/Account.tsx b/dashboard/web/pages/Account.tsx
index dddbc65d78df415f9df12c5b1a5107f6e12f781a..263a1c54291d88ba48321f4357de0a638526249c 100644
--- a/dashboard/web/pages/Account.tsx
+++ b/dashboard/web/pages/Account.tsx
@@ -1,7 +1,7 @@
import { useSearchParams } from "@solidjs/router";
import { createResource, createSignal, For, Show } from "solid-js";
import { parseResponse } from "hono/client";
-import type { User } from "../types/users.ts";
+import type { User, CommitIdentity as Identity } from "../types/users.ts";
import { api, reason } from "../api.ts";
import { Ago } from "../components/Ago.tsx";
import { lastGood, Loaded } from "../components/Loaded.tsx";
@@ -73,6 +73,7 @@ export function Account() {
Finish your profile and change any temporary password to open Snowglobe and Files.
+
)}
@@ -227,3 +228,85 @@ function SignIn(props: { user: Self }) {