forked from yvv/decision
v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
//! trustinWallet — access management, for its administrators only — and the
|
||||
//! « Retour » channel that turns usage tests into controls.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{self, IDENTITY_COLUMNS, IdentityRow, User};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
fn admin_only(user: &IdentityRow) -> AppResult<()> {
|
||||
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
|
||||
}
|
||||
|
||||
pub async fn overview(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Member {
|
||||
group_slug: String,
|
||||
identity_id: String,
|
||||
granted_at: String,
|
||||
}
|
||||
let groups: Vec<(String, String, String, bool)> =
|
||||
sqlx::query_as("SELECT slug, name, policy, hidden FROM groups ORDER BY position").fetch_all(&state.db).await?;
|
||||
let members = sqlx::query_as::<_, Member>("SELECT group_slug, identity_id, granted_at FROM memberships")
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let identities = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||
"SELECT {IDENTITY_COLUMNS} FROM identities ORDER BY admin DESC, display_name COLLATE NOCASE"
|
||||
))
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let last_seen: Vec<(String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?;
|
||||
let invitations: Vec<(String, String, bool, String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT token_hash, label, admin, groups, expires_at, identity_id FROM invitations
|
||||
WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(util::now())
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"groups": groups.iter().map(|(slug, name, policy, hidden)| json!({
|
||||
"slug": slug, "name": name, "policy": policy, "hidden": hidden,
|
||||
"members": members.iter().filter(|m| &m.group_slug == slug)
|
||||
.map(|m| json!({ "identityId": m.identity_id, "grantedAt": m.granted_at })).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"identities": identities.iter().map(|i| json!({
|
||||
"id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin,
|
||||
"lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()),
|
||||
})).collect::<Vec<_>>(),
|
||||
"invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({
|
||||
"id": hash, "label": label, "admin": admin,
|
||||
"groups": serde_json::from_str::<Vec<String>>(groups).unwrap_or_default(),
|
||||
"expiresAt": expires, "identityId": identity,
|
||||
})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GrantBody {
|
||||
identity_id: String,
|
||||
}
|
||||
|
||||
pub async fn grant(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(slug): Path<String>,
|
||||
Json(b): Json<GrantBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
auth::load_identity(&state, &b.identity_id).await?;
|
||||
let done = sqlx::query(
|
||||
"INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at)
|
||||
SELECT slug, ?, ?, ? FROM groups WHERE slug = ?",
|
||||
)
|
||||
.bind(&b.identity_id)
|
||||
.bind(&user.id)
|
||||
.bind(util::now())
|
||||
.bind(&slug)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(group = %slug, identity = %b.identity_id, by = %user.id, "access granted");
|
||||
Ok(Json(json!({ "granted": done.rows_affected() == 1 })))
|
||||
}
|
||||
|
||||
pub async fn revoke(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path((slug, identity)): Path<(String, String)>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let done = sqlx::query("DELETE FROM memberships WHERE group_slug = ? AND identity_id = ?")
|
||||
.bind(&slug)
|
||||
.bind(&identity)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
tracing::info!(group = %slug, identity = %identity, by = %user.id, "access revoked");
|
||||
Ok(Json(json!({ "revoked": done.rows_affected() == 1 })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteBody {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
admin: bool,
|
||||
#[serde(default)]
|
||||
groups: Vec<String>,
|
||||
identity_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The link is returned once and never stored in clear.
|
||||
pub async fn invite(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Json(b): Json<InviteBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let label = b.label.trim();
|
||||
if label.is_empty() || label.chars().count() > 60 {
|
||||
return Err(AppError::Invalid("label"));
|
||||
}
|
||||
if let Some(id) = &b.identity_id {
|
||||
auth::load_identity(&state, id).await?;
|
||||
}
|
||||
let token =
|
||||
auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?;
|
||||
Ok(Json(json!({ "link": auth::invitation_link(&state, &token) })))
|
||||
}
|
||||
|
||||
pub async fn cancel_invitation(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(hash): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
sqlx::query("UPDATE invitations SET expires_at = ? WHERE token_hash = ? AND used_at IS NULL")
|
||||
.bind(util::now())
|
||||
.bind(&hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminBody {
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
pub async fn set_admin(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<AdminBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
auth::load_identity(&state, &id).await?;
|
||||
if !b.admin {
|
||||
let others: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1 AND id != ?")
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if others == 0 {
|
||||
return Err(AppError::Conflict("last_admin"));
|
||||
}
|
||||
}
|
||||
sqlx::query("UPDATE identities SET admin = ? WHERE id = ?").bind(b.admin).bind(&id).execute(&state.db).await?;
|
||||
tracing::info!(identity = %id, admin = b.admin, by = %user.id, "administrator changed");
|
||||
Ok(Json(json!({ "admin": b.admin })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FeedbackBody {
|
||||
route: String,
|
||||
body: String,
|
||||
context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn send_feedback(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Json(b): Json<FeedbackBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let body = b.body.trim();
|
||||
if body.is_empty() || body.chars().count() > 4000 || b.route.len() > 300 {
|
||||
return Err(AppError::Invalid("body"));
|
||||
}
|
||||
let context = b.context.map(|c| c.to_string()).filter(|c| c.len() < 4000);
|
||||
sqlx::query("INSERT INTO feedback (identity_id, route, body, context, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(&user.id)
|
||||
.bind(&b.route)
|
||||
.bind(body)
|
||||
.bind(context)
|
||||
.bind(util::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn feedback_list(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
#[derive(sqlx::FromRow, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Row {
|
||||
id: i64,
|
||||
author: Option<String>,
|
||||
route: String,
|
||||
body: String,
|
||||
context: Option<String>,
|
||||
created_at: String,
|
||||
status: String,
|
||||
}
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT f.id, i.display_name AS author, f.route, f.body, f.context, f.created_at, f.status
|
||||
FROM feedback f LEFT JOIN identities i ON i.id = f.identity_id ORDER BY f.id DESC LIMIT 500",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "feedback": rows })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StatusBody {
|
||||
status: String,
|
||||
}
|
||||
|
||||
pub async fn feedback_status(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<i64>,
|
||||
Json(b): Json<StatusBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
if !matches!(b.status.as_str(), "new" | "seen" | "done") {
|
||||
return Err(AppError::Invalid("status"));
|
||||
}
|
||||
sqlx::query("UPDATE feedback SET status = ? WHERE id = ?").bind(&b.status).bind(id).execute(&state.db).await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
Reference in New Issue
Block a user