forked from yvv/decision
Douzième relecture (850f9c4 : ok), défauts anciens de publish.rs mis en
jeu par le nouveau dépôt :
- un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans
droit d'écriture) repart au passage suivant, au lieu d'une file marquée
faite avec un dépôt vide ;
- un changement de remote vers un dépôt neuf et vide lui envoie
l'historique ;
- au démarrage, ce qui attendait repart aussitôt (le délai croissant
pouvait atteindre 6 h).
trustinWallet montre l'état de la publication (en attente, dernier échec,
dernier commit) et offre « Publier maintenant ».
Boucle : 69 tests Rust, svelte-check, 9 vitest, e2e 13/13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
9.9 KiB
Rust
278 lines
9.9 KiB
Rust
//! 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 came_by: Vec<(String, String)> =
|
|
sqlx::query_as("SELECT i.id, t.label FROM identities i JOIN team_links t ON t.token_hash = i.team_link")
|
|
.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?;
|
|
let mut publication = json!({
|
|
"remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()),
|
|
"publicKey": crate::publish::public_key(&state),
|
|
});
|
|
if state.cfg.publish.is_some() {
|
|
publication["status"] = crate::publish::status(&state).await?;
|
|
}
|
|
Ok(Json(json!({
|
|
"publication": publication,
|
|
"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()),
|
|
"teamLink": came_by.iter().find(|(id, _)| id == &i.id).map(|(_, label)| label.clone()),
|
|
})).collect::<Vec<_>>(),
|
|
"teamLinks": crate::team::list(&state).await?,
|
|
"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
|
|
|| label.chars().any(char::is_control)
|
|
|| ld_engine::edit::has_hidden_controls(label)
|
|
{
|
|
return Err(AppError::Invalid("label"));
|
|
}
|
|
// A new link for an existing person is for invitation identities only: a link
|
|
// to a Ğ1 identity would let an administrator open a session in its name.
|
|
if let Some(id) = &b.identity_id
|
|
&& auth::load_identity(&state, id).await?.kind != "invite"
|
|
{
|
|
return Err(AppError::Invalid("identity_kind"));
|
|
}
|
|
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 })))
|
|
}
|
|
|
|
/// « Publier maintenant » : a key just added, a remote just created — no need
|
|
/// to wait for the backoff.
|
|
pub async fn publish_now(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
|
admin_only(&user)?;
|
|
if state.cfg.publish.is_none() {
|
|
return Err(AppError::Conflict("publication_off"));
|
|
}
|
|
crate::publish::retry_now(&state).await?;
|
|
tracing::info!(by = %user.id, "publication retried now");
|
|
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 })))
|
|
}
|