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>
This commit is contained in:
@@ -89,17 +89,41 @@ fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F)
|
||||
}
|
||||
|
||||
/// The only spaces free to change; any other (carriage return, tab, hair,
|
||||
/// thin, ogham…) is kept as it is.
|
||||
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n'];
|
||||
/// The only spaces free to change between words; any other (carriage return,
|
||||
/// tab, hair, thin, ogham…) is kept as it is, and so are line breaks and the
|
||||
/// indentation that opens a line: in Markdown they make lists and paragraphs.
|
||||
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}'];
|
||||
|
||||
/// Invisible format characters (zero-width, bidirectional overrides and
|
||||
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
|
||||
/// Source"). A text of reference never needs them; they are refused.
|
||||
/// Invisible characters: the Unicode default-ignorable set (zero-width,
|
||||
/// bidirectional overrides and isolates, soft hyphen, variation selectors,
|
||||
/// tags, byte order mark…) and control characters other than line breaks and
|
||||
/// tabs (an escape sequence rewrites a terminal). They can make "12" display as
|
||||
/// "21" ("Trojan Source") or hide text in the published files. A text of
|
||||
/// reference never needs them; they are refused.
|
||||
pub fn has_hidden_controls(s: &str) -> bool {
|
||||
s.chars().any(
|
||||
|c| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0x061C | 0xFEFF),
|
||||
)
|
||||
s.chars().any(|c| {
|
||||
(c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
|
||||
|| matches!(
|
||||
c as u32,
|
||||
0x00AD
|
||||
| 0x034F
|
||||
| 0x061C
|
||||
| 0x115F..=0x1160
|
||||
| 0x17B4..=0x17B5
|
||||
| 0x180B..=0x180F
|
||||
| 0x200B..=0x200F
|
||||
| 0x202A..=0x202E
|
||||
| 0x2060..=0x206F
|
||||
| 0x3164
|
||||
| 0xFE00..=0xFE0F
|
||||
| 0xFEFF
|
||||
| 0xFFA0
|
||||
| 0xFFF0..=0xFFF8
|
||||
| 0x1BCA0..=0x1BCA3
|
||||
| 0x1D173..=0x1D17A
|
||||
| 0xE0000..=0xE0FFF
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Apostrophes and quotation marks of every style are one and the same sign:
|
||||
@@ -123,7 +147,16 @@ fn shape(s: &str) -> String {
|
||||
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
|
||||
let mut out = String::new();
|
||||
let mut in_word = false;
|
||||
let mut line_start = true;
|
||||
for (i, &c) in cs.iter().enumerate() {
|
||||
if c == '\n' || (line_start && c.is_whitespace()) {
|
||||
// Line breaks and indentation are structure: « - a\n- b » is a list.
|
||||
out.push(c);
|
||||
in_word = false;
|
||||
line_start = true;
|
||||
continue;
|
||||
}
|
||||
line_start = false;
|
||||
if is_latin_letter(c) {
|
||||
if !in_word {
|
||||
out.push('a');
|
||||
@@ -203,16 +236,17 @@ fn words(s: &str) -> Vec<String> {
|
||||
/// « latransmission » → « la transmission ». A boundary moved is not a typo
|
||||
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
|
||||
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
|
||||
let joined = |x: &String, y: &String| !NEGATIONS.contains(&x.as_str()) && !NEGATIONS.contains(&y.as_str());
|
||||
// Neither the word glued or split nor its pieces may be a negation (« ja mais »).
|
||||
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t));
|
||||
let (mut i, mut j, mut moves) = (0, 0, 0);
|
||||
while i < a.len() && j < b.len() {
|
||||
if a[i] == b[j] {
|
||||
(i, j) = (i + 1, j + 1);
|
||||
continue;
|
||||
}
|
||||
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&b[j], &b[j + 1]) {
|
||||
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&a[i], &b[j], &b[j + 1]) {
|
||||
(i, j) = (i + 1, j + 2);
|
||||
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&a[i], &a[i + 1]) {
|
||||
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&b[j], &a[i], &a[i + 1]) {
|
||||
(i, j) = (i + 2, j + 1);
|
||||
} else {
|
||||
return false;
|
||||
@@ -389,6 +423,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negation_is_never_cut_nor_formed() {
|
||||
// Eighth independent review, 2026-10-21: the pieces were checked, not the word.
|
||||
for (old, new) in [
|
||||
("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"),
|
||||
("aucun compte", "au cun compte"),
|
||||
("sans retour", "san s retour"),
|
||||
("rien de plus", "ri en de plus"),
|
||||
("nowhere else", "now here else"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_and_indentation_are_structure() {
|
||||
for (old, new) in [
|
||||
("- une garantie\n- une autre", "- une garantie - une autre"),
|
||||
("- une garantie\n - une précision", "- une garantie\n- une précision"),
|
||||
("la\ntransmission", "la transmission"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old:?} → {new:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
classify("- une garantie\n- latransmission", "- une garantie\n- la transmission"),
|
||||
EditKind::Typographic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_spaces_and_look_alike_letters_are_kept() {
|
||||
assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive);
|
||||
@@ -518,6 +582,11 @@ mod tests {
|
||||
assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**"));
|
||||
assert!(has_hidden_controls("5\u{200b}0"));
|
||||
assert!(!has_hidden_controls("Je m’engage — « à 50 % »."));
|
||||
assert!(!has_hidden_controls("une ligne\nune autre\tavec tabulation"));
|
||||
// The whole default-ignorable set and the control characters (eighth review).
|
||||
for hidden in ["\u{ad}", "\u{180e}", "\u{fe0f}", "\u{e0041}", "\u{206e}", "\u{1b}[2J", "\u{7f}", "\u{85}"] {
|
||||
assert!(has_hidden_controls(&format!("cer{hidden}tification")), "{hidden:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Team links (2026-10-21): one link shared with a team until a date; each opening
|
||||
-- makes its own invited identity, never an administrator. Additive only.
|
||||
|
||||
CREATE TABLE team_links (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL, -- what the link is for, shown to whoever opens it
|
||||
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on each opening
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
max_uses INTEGER NOT NULL,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
-- The team link an identity came from, if any (shown to administrators).
|
||||
ALTER TABLE identities ADD COLUMN team_link TEXT;
|
||||
@@ -34,6 +34,10 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
|
||||
.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",
|
||||
@@ -55,7 +59,9 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
|
||||
"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(),
|
||||
@@ -126,7 +132,7 @@ pub async fn invite(
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let label = b.label.trim();
|
||||
if label.is_empty() || label.chars().count() > 60 {
|
||||
if label.is_empty() || label.chars().count() > 60 || 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
|
||||
|
||||
@@ -121,7 +121,7 @@ impl FromRequestParts<AppState> for User {
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderValue> {
|
||||
pub(crate) async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderValue> {
|
||||
let token = util::token();
|
||||
sqlx::query("INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(util::sha256_hex(&token))
|
||||
@@ -146,7 +146,12 @@ pub async fn load_identity(state: &AppState, id: &str) -> AppResult<IdentityRow>
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
async fn grant_groups(state: &AppState, identity_id: &str, groups: &[String], by: Option<&str>) -> AppResult<()> {
|
||||
pub(crate) async fn grant_groups(
|
||||
state: &AppState,
|
||||
identity_id: &str,
|
||||
groups: &[String],
|
||||
by: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
for slug in groups {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at)
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct Connect {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Publish {
|
||||
/// Remote of the texts repository, e.g. `git@git.duniter.org:Yvv/libredecision-textes.git`.
|
||||
/// Remote of the texts repository, e.g. `gitea@git.open.us.org:yvv/libredecision-textes.git`.
|
||||
pub remote: String,
|
||||
pub branch: String,
|
||||
/// Private SSH key allowed to push there (deploy key).
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod error;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod team;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
pub mod wot;
|
||||
@@ -96,6 +97,8 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/auth/g1/start", get(auth::g1_start))
|
||||
.route("/auth/g1/callback", get(auth::g1_callback))
|
||||
.route("/auth/invitation", post(auth::accept_invitation))
|
||||
.route("/auth/team", post(team::join))
|
||||
.route("/auth/team/peek", post(team::peek))
|
||||
.route("/auth/dev", post(auth::dev_login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/me", get(auth::me))
|
||||
@@ -120,6 +123,8 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/access/groups/{slug}/members/{identity}", delete(access::revoke))
|
||||
.route("/access/invitations", post(access::invite))
|
||||
.route("/access/invitations/{hash}", delete(access::cancel_invitation))
|
||||
.route("/access/team-links", post(team::create))
|
||||
.route("/access/team-links/{hash}", delete(team::revoke))
|
||||
.route("/access/identities/{id}/admin", post(access::set_admin))
|
||||
.route("/access/feedback", get(access::feedback_list))
|
||||
.route("/access/feedback/{id}", post(access::feedback_status))
|
||||
|
||||
@@ -62,7 +62,7 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||
if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) {
|
||||
return Err(AppError::Invalid("hidden_characters"));
|
||||
}
|
||||
if title.trim().is_empty() || title.chars().count() > MAX_TITLE {
|
||||
if title.trim().is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
|
||||
return Err(AppError::Invalid("title"));
|
||||
}
|
||||
if (needs_body && body.trim().is_empty()) || body.chars().count() > MAX_BODY {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! 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())
|
||||
}
|
||||
@@ -251,6 +251,11 @@ async fn editing_is_not_voting() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
||||
// A title is one line.
|
||||
let (s, v) = b
|
||||
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(json!({ "title": "Récipro\ncité", "body": body })))
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("title")));
|
||||
// The title never absorbs the start of the body.
|
||||
let title = e2["title"].as_str().expect("title");
|
||||
let (head, rest) = body.split_once(' ').expect("two words");
|
||||
@@ -460,6 +465,89 @@ async fn access_management_is_for_administrators_only() {
|
||||
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_team_link_makes_one_identity_per_opening() {
|
||||
let (mut admin, _) = Client::new().await;
|
||||
admin.login("Yvv", true).await;
|
||||
let mut user = admin.with(None);
|
||||
user.login("Chiara", false).await;
|
||||
let team = |groups: Value, days: i64, max: i64| json!({ "label": "Équipe dev", "groups": groups, "days": days, "maxUses": max });
|
||||
let (s, _) = user.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN, "administrators only");
|
||||
for (body, code) in [
|
||||
(team(json!([]), 30, 2), "groups"),
|
||||
(team(json!(["nope"]), 30, 2), "groups"),
|
||||
(team(json!(["essai"]), 0, 2), "team_limits"),
|
||||
(team(json!(["essai"]), 30, 501), "team_limits"),
|
||||
] {
|
||||
let (s, v) = admin.call("POST", "/api/access/team-links", Some(body)).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)));
|
||||
}
|
||||
let (s, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let link = v["link"].as_str().expect("link").to_owned();
|
||||
assert!(link.contains("/equipe#"), "{link}");
|
||||
let token = link.split('#').nth(1).expect("token").to_owned();
|
||||
|
||||
let mut first = admin.with(None);
|
||||
let (s, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["label"], "Équipe dev");
|
||||
assert_eq!(v["groups"].as_array().expect("g").len(), 1);
|
||||
assert_eq!(v["signedInAs"], Value::Null);
|
||||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lu\u{202e}na" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
|
||||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": " Luna " }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!((v["me"]["kind"].as_str(), v["me"]["admin"].as_bool()), (Some("invite"), Some(false)));
|
||||
assert_eq!(v["me"]["displayName"], "Luna");
|
||||
let luna = v["me"]["id"].as_str().expect("id").to_owned();
|
||||
let (_, g) = first.call("GET", "/api/groups", None).await;
|
||||
assert!(g["groups"].as_array().expect("g").iter().any(|g| g["slug"] == "essai" && g["open"] == true));
|
||||
|
||||
// Nobody joins under a name already there; the refusal does not count as an opening.
|
||||
let mut second = admin.with(None);
|
||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "luna" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
|
||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
|
||||
// Two openings allowed: the third is refused.
|
||||
let mut third = admin.with(None);
|
||||
let (s, v) = third.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Zoé" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("invitation_invalid")));
|
||||
|
||||
// Someone signed in keeps their identity and receives the groups.
|
||||
let (_, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["monnaie-libre"]), 7, 5))).await;
|
||||
let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
|
||||
assert_eq!(v["signedInAs"], "Luna");
|
||||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
|
||||
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// Administrators see the links still open and where each identity came from; a link revokes.
|
||||
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||||
let links = ov["teamLinks"].as_array().expect("links");
|
||||
assert_eq!(links.len(), 2, "{ov}");
|
||||
assert!(
|
||||
ov["identities"]
|
||||
.as_array()
|
||||
.expect("i")
|
||||
.iter()
|
||||
.any(|i| i["displayName"] == "Paul" && i["teamLink"] == "Équipe dev")
|
||||
);
|
||||
let open = links.iter().find(|l| l["uses"] == 1).expect("second link");
|
||||
let (s, _) =
|
||||
admin.call("DELETE", &format!("/api/access/team-links/{}", open["id"].as_str().expect("id")), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let mut late = admin.with(None);
|
||||
let (s, _) = late.call("POST", "/api/auth/team", Some(json!({ "token": token2, "name": "Tardif" }))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_monnaie_libre_only_web_of_trust_members_vote() {
|
||||
let (mut admin, _) = Client::new().await;
|
||||
|
||||
Reference in New Issue
Block a user