Users, file manager, image generation, budgets and agent workflow

- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+612
View File
@@ -0,0 +1,612 @@
//! Users, sign-in (local passwords + OpenID Connect) and access control.
use crate::api::{ApiError, ApiResult};
use crate::app::App;
use crate::config::expand_env;
use crate::store::User;
use anyhow::{Context, Result, anyhow, bail};
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
use axum::extract::{Path, Query, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
use axum::{Extension, Json};
use base64::Engine;
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub const COOKIE: &str = "codecraft_session";
const MIN_PASSWORD: usize = 8;
// --- primitives ---------------------------------------------------------------
pub fn random_token(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::fill(&mut buf[..]);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
}
pub fn sha256_hex(s: &str) -> String {
Sha256::digest(s.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
pub fn hash_password(password: &str) -> Result<String> {
let mut salt = [0u8; 16];
rand::fill(&mut salt);
let hash = Argon2::default()
.hash_password_with_salt(password.as_bytes(), &salt)
.map_err(|e| anyhow!("hashing failed: {e}"))?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, hash: &str) -> bool {
Argon2::default().verify_password(password.as_bytes(), hash).is_ok()
}
fn valid_username(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
&& !name.starts_with('.')
}
/// Usernames become directory names; derive a safe one from an IdP claim.
fn sanitize_username(raw: &str) -> String {
let local = raw.split('@').next().unwrap_or(raw);
let s: String = local
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c.to_ascii_lowercase() } else { '_' })
.collect();
s.trim_start_matches('.').chars().take(64).collect()
}
// --- cookies & middleware -------------------------------------------------------
fn cookie_secure(app: &App, headers: &HeaderMap) -> bool {
match app.config().auth.cookie_secure.as_str() {
"true" => true,
"false" => false,
_ => {
app.config().auth.public_url.starts_with("https://")
|| headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()) == Some("https")
}
}
}
fn session_cookie(app: &App, headers: &HeaderMap, token: &str, max_age_secs: i64) -> HeaderValue {
let secure = if cookie_secure(app, headers) { "; Secure" } else { "" };
HeaderValue::from_str(&format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}{secure}"))
.expect("valid cookie")
}
pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
if let Some(t) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
return Some(t.trim().to_string());
}
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|c| c.split(';'))
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
}
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
let token = token_from_headers(headers)?;
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
}
/// Paths reachable without a login.
fn is_public(path: &str) -> bool {
["/info", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter()
.any(|p| path.ends_with(p))
}
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
if is_public(req.uri().path()) {
return next.run(req).await;
}
match current_user(&app, req.headers()) {
Some(user) => {
req.extensions_mut().insert(user);
next.run(req).await
}
None => ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response(),
}
}
pub fn require_admin(user: &User) -> Result<(), ApiError> {
if user.is_admin() { Ok(()) } else { Err(ApiError(StatusCode::FORBIDDEN, "admin only".into())) }
}
fn issue_login(app: &App, headers: &HeaderMap, user: &User) -> Result<HeaderValue> {
let token = random_token(32);
let days = app.config().auth.session_days.max(1) as i64;
let ua = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
app.store.add_token(&sha256_hex(&token), &user.id, crate::types::now_ms() + days * 86_400_000, &ua.chars().take(200).collect::<String>())?;
Ok(session_cookie(app, headers, &token, days * 86_400))
}
// --- bootstrap ------------------------------------------------------------------
/// Create the first admin from the environment, or generate a setup code.
pub fn bootstrap(app: &App) -> Result<()> {
if app.store.user_count()? > 0 {
return Ok(());
}
if let (Ok(user), Ok(pass)) = (std::env::var("CODECRAFT_ADMIN_USER"), std::env::var("CODECRAFT_ADMIN_PASSWORD")) {
if valid_username(&user) && pass.len() >= MIN_PASSWORD {
let u = app.store.create_user(&user, &user, "admin", Some(&hash_password(&pass)?), None)?;
let n = app.store.adopt_orphans(&u.id)?;
tracing::info!("created admin '{user}' from the environment ({n} existing sessions assigned)");
return Ok(());
}
tracing::warn!("CODECRAFT_ADMIN_USER/PASSWORD ignored: invalid username or password shorter than {MIN_PASSWORD}");
}
let code = random_token(9);
tracing::warn!("no users yet – open the web UI and create the first admin with setup code: {code}");
*app.setup_code.lock().unwrap() = Some(code);
Ok(())
}
// --- endpoints: session -------------------------------------------------------
pub async fn info(State(app): State<Arc<App>>, headers: HeaderMap) -> Json<Value> {
let cfg = app.config();
let user = current_user(&app, &headers);
Json(json!({
"name": "CodeCraft",
"version": env!("CARGO_PKG_VERSION"),
"setup_required": app.setup_code.lock().unwrap().is_some(),
"oidc": if cfg.auth.oidc.enabled { json!({"label": cfg.auth.oidc.button_label}) } else { Value::Null },
"user": user,
}))
}
#[derive(Deserialize)]
pub struct LoginBody {
username: String,
password: String,
}
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
let found = app.store.user_by_name(body.username.trim())?;
// Verify even for unknown users so timing does not reveal which names exist.
let hash = found.as_ref().and_then(|(_, h)| h.clone());
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
};
let cookie = issue_login(&app, &headers, &user)?;
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
if let Some(t) = token_from_headers(&headers) {
let _ = app.store.delete_token(&sha256_hex(&t));
}
([(header::SET_COOKIE, session_cookie(&app, &headers, "", 0))], Json(json!({"ok": true}))).into_response()
}
#[derive(Deserialize)]
pub struct SetupBody {
code: String,
username: String,
password: String,
}
pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<SetupBody>) -> ApiResult<Response> {
let expected = app.setup_code.lock().unwrap().clone();
let Some(expected) = expected else {
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
};
if body.code.trim() != expected {
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
}
let name = body.username.trim();
if !valid_username(name) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username".into()));
}
if body.password.len() < MIN_PASSWORD {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
}
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
app.store.adopt_orphans(&user.id)?;
*app.setup_code.lock().unwrap() = None;
let cookie = issue_login(&app, &headers, &user)?;
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
#[derive(Deserialize)]
pub struct PasswordBody {
current: Option<String>,
new: String,
}
/// Change the own password (OIDC-only users may set one without `current`).
pub async fn change_password(State(app): State<Arc<App>>, Extension(user): Extension<User>, headers: HeaderMap, Json(body): Json<PasswordBody>) -> ApiResult {
if body.new.len() < MIN_PASSWORD {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
}
if let Some((_, Some(hash))) = app.store.user_by_name(&user.username)? {
if !verify_password(body.current.as_deref().unwrap_or(""), &hash) {
return Err(ApiError(StatusCode::UNAUTHORIZED, "current password is wrong".into()));
}
}
app.store.update_user(&user.id, None, None, None, Some(&hash_password(&body.new)?), None)?;
// Sign out all other devices.
app.store.delete_user_tokens(&user.id)?;
let _ = issue_login(&app, &headers, &user)?;
Ok(Json(json!({"ok": true})))
}
// --- endpoints: user management (admin) ------------------------------------------
pub async fn list_users(Extension(me): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
require_admin(&me)?;
Ok(Json(json!(app.store.users()?)))
}
/// Minimal user list for sharing dialogs (all signed-in users).
pub async fn directory(State(app): State<Arc<App>>) -> ApiResult {
let list: Vec<Value> = app
.store
.users()?
.into_iter()
.filter(|u| !u.disabled)
.map(|u| json!({"id": u.id, "username": u.username, "display_name": u.display_name}))
.collect();
Ok(Json(json!(list)))
}
#[derive(Deserialize)]
pub struct CreateUserBody {
username: String,
#[serde(default)]
display_name: String,
password: Option<String>,
#[serde(default = "default_role")]
role: String,
}
fn default_role() -> String {
"user".into()
}
fn check_role(role: &str) -> Result<(), ApiError> {
if role == "admin" || role == "user" { Ok(()) } else { Err(ApiError(StatusCode::BAD_REQUEST, "role must be admin or user".into())) }
}
pub async fn create_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Json(body): Json<CreateUserBody>) -> ApiResult {
require_admin(&me)?;
let name = body.username.trim();
if !valid_username(name) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username (letters, digits, - _ .)".into()));
}
check_role(&body.role)?;
let hash = match body.password.as_deref().filter(|p| !p.is_empty()) {
Some(p) if p.len() < MIN_PASSWORD => {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
}
Some(p) => Some(hash_password(p)?),
None => None,
};
if app.store.user_by_name(name)?.is_some() {
return Err(ApiError(StatusCode::CONFLICT, "username already exists".into()));
}
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
Ok(Json(json!(user)))
}
#[derive(Deserialize)]
pub struct UpdateUserBody {
display_name: Option<String>,
role: Option<String>,
disabled: Option<bool>,
password: Option<String>,
}
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
require_admin(&me)?;
let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?;
if let Some(r) = &body.role {
check_role(r)?;
}
let loses_admin = target.is_admin() && !target.disabled && (body.role.as_deref().is_some_and(|r| r != "admin") || body.disabled == Some(true));
if loses_admin && app.store.admin_count()? <= 1 {
return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be demoted or disabled".into()));
}
let hash = match body.password.as_deref().filter(|p| !p.is_empty()) {
Some(p) if p.len() < MIN_PASSWORD => {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
}
Some(p) => Some(hash_password(p)?),
None => None,
};
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
if hash.is_some() {
app.store.delete_user_tokens(&id)?;
}
Ok(Json(json!(app.store.user(&id)?)))
}
pub async fn delete_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>) -> ApiResult {
require_admin(&me)?;
let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?;
if target.id == me.id {
return Err(ApiError(StatusCode::CONFLICT, "you cannot delete yourself".into()));
}
if target.is_admin() && app.store.admin_count()? <= 1 {
return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be deleted".into()));
}
// Sessions stay (statistics); they become visible to admins only.
app.store.delete_user(&id)?;
Ok(Json(json!({"ok": true})))
}
// --- OpenID Connect -----------------------------------------------------------------
pub struct OidcPending {
nonce: String,
verifier: String,
created: std::time::Instant,
}
async fn discovery(app: &App) -> Result<Value> {
let cfg = app.config();
let issuer = expand_env(&cfg.auth.oidc.issuer);
if let Some((iss, doc)) = app.oidc_discovery.lock().unwrap().clone() {
if iss == issuer {
return Ok(doc);
}
}
let url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/'));
let doc: Value = crate::provider::http_client().get(&url).send().await?.error_for_status()?.json().await?;
*app.oidc_discovery.lock().unwrap() = Some((issuer, doc.clone()));
Ok(doc)
}
fn redirect_uri(app: &App) -> Result<String> {
let base = app.config().auth.public_url.trim_end_matches('/').to_string();
if base.is_empty() {
bail!("auth.public_url must be set for single sign-on");
}
Ok(format!("{base}/api/auth/oidc/callback"))
}
fn error_page(message: &str) -> Response {
let msg = message.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
(
StatusCode::FORBIDDEN,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
format!("<!doctype html><meta charset=utf-8><title>CodeCraft</title><body style=\"font-family:sans-serif;padding:2em\"><h2>Sign-in failed</h2><p>{msg}</p><p><a href=\"/\">Back</a></p>"),
)
.into_response()
}
pub async fn oidc_start(State(app): State<Arc<App>>) -> Response {
match oidc_start_inner(&app).await {
Ok(url) => Redirect::to(&url).into_response(),
Err(e) => error_page(&format!("{e:#}")),
}
}
async fn oidc_start_inner(app: &App) -> Result<String> {
let cfg = app.config();
if !cfg.auth.oidc.enabled {
bail!("single sign-on is not enabled");
}
let doc = discovery(app).await?;
let endpoint = doc["authorization_endpoint"].as_str().context("no authorization_endpoint")?;
let state = random_token(24);
let nonce = random_token(24);
let verifier = random_token(48);
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
{
let mut pending = app.oidc_pending.lock().unwrap();
pending.retain(|_, p| p.created.elapsed().as_secs() < 600);
pending.insert(state.clone(), OidcPending { nonce: nonce.clone(), verifier, created: std::time::Instant::now() });
}
let mut url = reqwest::Url::parse(endpoint)?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &expand_env(&cfg.auth.oidc.client_id))
.append_pair("redirect_uri", &redirect_uri(app)?)
.append_pair("scope", &cfg.auth.oidc.scopes.join(" "))
.append_pair("state", &state)
.append_pair("nonce", &nonce)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
Ok(url.to_string())
}
#[derive(Deserialize)]
pub struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
pub async fn oidc_callback(State(app): State<Arc<App>>, headers: HeaderMap, Query(q): Query<CallbackQuery>) -> Response {
if let Some(e) = q.error {
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
}
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
Ok(user) => match issue_login(&app, &headers, &user) {
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
Err(e) => error_page(&format!("{e:#}")),
},
Err(e) => {
tracing::warn!("oidc sign-in failed: {e:#}");
error_page(&format!("{e:#}"))
}
}
}
fn decode_jwt_claims(jwt: &str) -> Result<Value> {
let payload = jwt.split('.').nth(1).context("malformed id_token")?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.context("malformed id_token payload")?;
Ok(serde_json::from_slice(&bytes)?)
}
fn claim_list(v: &Value) -> Vec<String> {
match v {
Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
Value::String(s) => s.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect(),
_ => vec![],
}
}
async fn oidc_callback_inner(app: &App, code: String, state: String) -> Result<User> {
let cfg = app.config();
let oc = &cfg.auth.oidc;
let pending = app.oidc_pending.lock().unwrap().remove(&state).context("unknown or expired sign-in attempt – please try again")?;
let doc = discovery(app).await?;
let token_endpoint = doc["token_endpoint"].as_str().context("no token_endpoint")?;
let client_id = expand_env(&oc.client_id);
let resp = crate::provider::http_client()
.post(token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", code.as_str()),
("redirect_uri", redirect_uri(app)?.as_str()),
("client_id", client_id.as_str()),
("client_secret", expand_env(&oc.client_secret).as_str()),
("code_verifier", pending.verifier.as_str()),
])
.send()
.await?;
if !resp.status().is_success() {
bail!("token exchange failed: HTTP {} {}", resp.status(), resp.text().await.unwrap_or_default().chars().take(300).collect::<String>());
}
let tokens: Value = resp.json().await?;
// The id_token comes straight from the token endpoint over TLS, which
// authenticates the issuer (OIDC Core 3.1.3.7); we still check its claims.
let mut claims = decode_jwt_claims(tokens["id_token"].as_str().context("no id_token in response")?)?;
let issuer = expand_env(&oc.issuer);
if claims["iss"].as_str().map(|s| s.trim_end_matches('/')) != Some(issuer.trim_end_matches('/')) {
bail!("id_token issuer mismatch");
}
let aud_ok = match &claims["aud"] {
Value::String(a) => *a == client_id,
Value::Array(a) => a.iter().any(|x| x.as_str() == Some(&client_id)),
_ => false,
};
if !aud_ok {
bail!("id_token audience mismatch");
}
if claims["nonce"].as_str() != Some(pending.nonce.as_str()) {
bail!("id_token nonce mismatch");
}
if claims["exp"].as_i64().unwrap_or(0) < chrono::Utc::now().timestamp() {
bail!("id_token expired");
}
// Groups and names are often only in the userinfo response.
if let (Some(ep), Some(at)) = (doc["userinfo_endpoint"].as_str(), tokens["access_token"].as_str()) {
if let Ok(r) = crate::provider::http_client().get(ep).bearer_auth(at).send().await {
if let Ok(info) = r.json::<Value>().await {
if info["sub"] == claims["sub"] {
for (k, v) in info.as_object().cloned().unwrap_or_default() {
if claims.get(&k).is_none() {
claims[&k] = v;
}
}
}
}
}
}
let sub = claims["sub"].as_str().context("no sub claim")?.to_string();
let groups = claim_list(&claims[&oc.groups_claim]);
if !oc.allowed_group.is_empty() && !groups.contains(&oc.allowed_group) {
bail!("your account is not allowed to use CodeCraft");
}
let is_admin = !oc.admin_group.is_empty() && groups.contains(&oc.admin_group);
let display = claims["name"].as_str().unwrap_or("").to_string();
let user = if let Some(u) = app.store.user_by_oidc(&sub)? {
u
} else {
let raw = claims[&oc.username_claim]
.as_str()
.or_else(|| claims["preferred_username"].as_str())
.or_else(|| claims["email"].as_str())
.unwrap_or(&sub);
let name = sanitize_username(raw);
if !valid_username(&name) {
bail!("cannot derive a valid username from the '{}' claim", oc.username_claim);
}
match app.store.user_by_name(&name)? {
Some((existing, _)) if oc.link_by_username && existing.oidc_sub_free() => {
app.store.update_user(&existing.id, None, None, None, None, Some(&sub))?;
existing
}
Some(_) => bail!("the username '{name}' is already taken by a local account"),
None if oc.auto_create => {
let first = app.store.user_count()? == 0;
let role = if is_admin || first { "admin" } else { "user" };
let u = app.store.create_user(&name, if display.is_empty() { &name } else { &display }, role, None, Some(&sub))?;
if first {
app.store.adopt_orphans(&u.id)?;
*app.setup_code.lock().unwrap() = None;
}
u
}
None => bail!("no CodeCraft account exists for '{name}'"),
}
};
if user.disabled {
bail!("this account is disabled");
}
// Keep the role in sync with the IdP group (never removing the last admin).
if !oc.admin_group.is_empty() && user.is_admin() != is_admin && (is_admin || app.store.admin_count()? > 1) {
app.store.update_user(&user.id, None, Some(if is_admin { "admin" } else { "user" }), None, None, None)?;
}
Ok(app.store.user(&user.id)?.context("user vanished")?)
}
impl User {
fn oidc_sub_free(&self) -> bool {
!self.oidc
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_roundtrip() {
let h = hash_password("correct horse").unwrap();
assert!(verify_password("correct horse", &h));
assert!(!verify_password("wrong", &h));
}
#[test]
fn usernames() {
assert!(valid_username("zink"));
assert!(!valid_username("../etc"));
assert!(!valid_username(".hidden"));
assert_eq!(sanitize_username("Richard.Zink@example.org"), "richard.zink");
assert_eq!(sanitize_username("a/b c"), "a_b_c");
}
#[test]
fn jwt_claims() {
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"sub":"1","aud":"x"}"#);
let v = decode_jwt_claims(&format!("h.{payload}.s")).unwrap();
assert_eq!(v["sub"], "1");
}
}