Files
decision/crates/ld-server/src/team.rs
T
YvvandClaude Opus 5.5 fd19dbf4ec v3 : liens d'équipe, publication sur git.open.us.org, huitième relecture
Liens d'équipe (Yvv, 2026-10-21) : un lien réutilisable jusqu'à une date
et pour un nombre de personnes, révocable. Chaque ouverture demande un nom
et crée sa propre identité d'invitation, jamais administratrice ; un nom
déjà pris est refusé ; une personne déjà connectée reçoit les groupes.
trustinWallet montre par quel lien chaque identité est venue, et donne un
nouveau lien de connexion à une personne invitée (autre appareil).
Migration additive 0002 (table team_links, colonne identities.team_link).

Publication : yvv/libredecision-textes sur git.open.us.org, pour
l'instant (un dépôt pour l'ouverture, puis le dépôt officiel, viendront).

Huitième relecture (c3d6bf8 : ok), points corrigés :
- une négation n'est jamais coupée ni formée par collage (« ja mais ») ;
- caractères invisibles : tout l'ensemble Unicode des ignorables et les
  contrôles autres que saut de ligne et tabulation ;
- sauts de ligne et indentation font partie de la forme (listes) ;
- un titre tient sur une ligne.

Boucle : 65 tests Rust, svelte-check, 9 vitest, e2e 13/13 (C12 ajouté).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:12:21 +02:00

250 lines
8.3 KiB
Rust

//! Team links — one link an administrator shares with a team, until a date and
//! for a number of openings. Each opening asks the person's name and makes
//! **their own** invited identity, never an administrator: one person, one
//! voice, and access management stays with trustinWallet's administrators.
//! Someone already signed in keeps their identity and receives the groups.
//!
//! Limit, assumed: a person who opens the link twice under two names has two
//! identities. Where invited identities vote (the test group), administrators
//! see in trustinWallet which link each identity came from.
use crate::AppState;
use crate::auth::{self, IdentityRow, User, Viewer};
use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::header::SET_COOKIE;
use axum::response::{IntoResponse, Response};
use ld_engine::edit;
use serde::Deserialize;
use serde_json::json;
const MAX_DAYS: i64 = 90;
const MAX_USES: i64 = 500;
const MAX_NAME: usize = 60;
pub fn link(state: &AppState, token: &str) -> String {
format!("{}/equipe#{token}", state.cfg.public_url)
}
#[derive(sqlx::FromRow)]
struct TeamLink {
token_hash: String,
label: String,
groups: String,
created_by: String,
expires_at: String,
}
/// A link that still opens: not revoked, not expired, openings left.
async fn open_link(state: &AppState, token: &str) -> AppResult<TeamLink> {
sqlx::query_as::<_, TeamLink>(
"SELECT token_hash, label, groups, created_by, expires_at FROM team_links
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
)
.bind(util::sha256_hex(token.trim()))
.bind(util::now())
.fetch_optional(&state.db)
.await?
.ok_or(AppError::Invalid("invitation_invalid"))
}
fn slugs(raw: &str) -> Vec<String> {
serde_json::from_str(raw).unwrap_or_default()
}
#[derive(Deserialize)]
pub struct PeekBody {
token: String,
}
/// What the link opens, before the person gives a name.
pub async fn peek(
State(state): State<AppState>,
Viewer(viewer): Viewer,
Json(b): Json<PeekBody>,
) -> AppResult<Json<serde_json::Value>> {
let l = open_link(&state, &b.token).await?;
let mut names = Vec::new();
for slug in slugs(&l.groups) {
if let Some(name) = sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE slug = ?")
.bind(&slug)
.fetch_optional(&state.db)
.await?
{
names.push(name);
}
}
Ok(Json(json!({
"label": l.label,
"groups": names,
"expiresAt": l.expires_at,
"signedInAs": viewer.map(|v| v.display_name),
})))
}
#[derive(Deserialize)]
pub struct JoinBody {
token: String,
#[serde(default)]
name: Option<String>,
}
fn check_name(raw: &str) -> AppResult<&str> {
let name = raw.trim();
let n = name.chars().count();
if !(2..=MAX_NAME).contains(&n) || name.chars().any(char::is_control) || edit::has_hidden_controls(name) {
return Err(AppError::Invalid("name"));
}
Ok(name)
}
pub async fn join(
State(state): State<AppState>,
Viewer(viewer): Viewer,
Json(b): Json<JoinBody>,
) -> AppResult<Response> {
let l = open_link(&state, &b.token).await?;
// Checked before an opening is counted.
let name = match &viewer {
Some(_) => None,
None => {
let name = check_name(b.name.as_deref().unwrap_or_default())?;
// Nobody joins under the name of someone already there.
let taken: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE display_name = ? COLLATE NOCASE")
.bind(name)
.fetch_one(&state.db)
.await?;
if taken > 0 {
return Err(AppError::Conflict("name_taken"));
}
Some(name.to_owned())
}
};
// Count the opening first: of two at the last place, one only goes on.
let counted = sqlx::query(
"UPDATE team_links SET uses = uses + 1
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
)
.bind(&l.token_hash)
.bind(util::now())
.execute(&state.db)
.await?;
if counted.rows_affected() != 1 {
return Err(AppError::Invalid("invitation_invalid"));
}
let (identity_id, session) = match (viewer, name) {
(Some(me), _) => (me.id, None),
(None, Some(name)) => {
let id = format!("inv:{}", util::id());
sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link)
VALUES (?, 'invite', ?, 0, 0, ?, ?)",
)
.bind(&id)
.bind(&name)
.bind(util::now())
.bind(&l.token_hash)
.execute(&state.db)
.await?;
let session = auth::open_session(&state, &id).await?;
(id, Some(session))
}
(None, None) => return Err(AppError::Invalid("name")),
};
auth::grant_groups(&state, &identity_id, &slugs(&l.groups), Some(&l.created_by)).await?;
tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened");
let me = auth::load_identity(&state, &identity_id).await?;
let mut res = Json(json!({ "me": me })).into_response();
if let Some(cookie) = session {
res.headers_mut().append(SET_COOKIE, cookie);
}
Ok(res)
}
fn admin_only(user: &IdentityRow) -> AppResult<()> {
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateBody {
label: String,
groups: Vec<String>,
days: i64,
max_uses: i64,
}
/// The link is returned once and never stored in clear.
pub async fn create(
State(state): State<AppState>,
User(user): User,
Json(b): Json<CreateBody>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
let label = b.label.trim();
if label.is_empty() || label.chars().count() > MAX_NAME || edit::has_hidden_controls(label) {
return Err(AppError::Invalid("label"));
}
if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) {
return Err(AppError::Invalid("team_limits"));
}
let known: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
if b.groups.is_empty() || b.groups.iter().any(|g| !known.contains(g)) {
return Err(AppError::Invalid("groups"));
}
let token = util::token();
sqlx::query(
"INSERT INTO team_links (token_hash, label, groups, created_by, created_at, expires_at, max_uses)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(util::sha256_hex(&token))
.bind(label)
.bind(serde_json::to_string(&b.groups).unwrap_or_else(|_| "[]".into()))
.bind(&user.id)
.bind(util::now())
.bind(util::in_days(b.days))
.bind(b.max_uses)
.execute(&state.db)
.await?;
tracing::info!(by = %user.id, days = b.days, max_uses = b.max_uses, "team link created");
Ok(Json(json!({ "link": link(&state, &token) })))
}
pub async fn revoke(
State(state): State<AppState>,
User(user): User,
Path(hash): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
sqlx::query("UPDATE team_links SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL")
.bind(util::now())
.bind(&hash)
.execute(&state.db)
.await?;
tracing::info!(by = %user.id, "team link revoked");
Ok(Json(json!({ "ok": true })))
}
/// The links still open, for trustinWallet.
pub async fn list(state: &AppState) -> AppResult<Vec<serde_json::Value>> {
let rows: Vec<(String, String, String, String, i64, i64)> = sqlx::query_as(
"SELECT token_hash, label, groups, expires_at, max_uses, uses FROM team_links
WHERE revoked_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
)
.bind(util::now())
.fetch_all(&state.db)
.await?;
Ok(rows
.into_iter()
.map(|(id, label, groups, expires, max_uses, uses)| {
json!({
"id": id, "label": label, "groups": slugs(&groups),
"expiresAt": expires, "maxUses": max_uses, "uses": uses,
})
})
.collect())
}