v3 : seconde relecture — une coquille ne touche ni chiffre, ni signe, ni relation ; retour arrière du déploiement
- Correction de coquille : un chiffre, un signe, un séparateur dans un nombre ou une relation changés (« >= » → « <= », « + » → « - », « 182.625 » → « 182 625 », « % » → « ‰ ») rendent la modification « de fond ». Testé dans le moteur et par l'API sur le vrai texte K1. - Déploiement : les conteneurs retirés de la racine qui tournaient sont notés ; si la suite échoue, l'étape « retablir » arrête le produit et les relance. Le produit est recréé à chaque déploiement (--force-recreate). - Cookies préfixés en https : __Host-ld_session, __Secure-ld_oauth (pas de cookie planté par un site voisin sous librodrome.org). - syft épinglé (v1.55.0) dans les deux pipelines : ce conteneur a le socket Docker. - Docs : limites connues assumées, groupe d'essai à deux voix, error.identity_kind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+47
-3
@@ -46,7 +46,8 @@ steps:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
|
||||
- mkdir -p .reports
|
||||
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
||||
- syft libredecision-app:latest -o cyclonedx-json@1.5 --file .reports/sbom-app.cyclonedx.json
|
||||
@@ -105,12 +106,19 @@ steps:
|
||||
# « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui
|
||||
# donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est
|
||||
# recréée par son propre workflow, sous son nouveau domaine.
|
||||
# Ceux qui tournaient sont notés dans /opt/libredecision-app/retires : si la suite
|
||||
# échoue, l'étape « retablir » les relance (et seulement eux).
|
||||
- |
|
||||
RETIRES=/opt/libredecision-app/retires
|
||||
: > "$RETIRES"
|
||||
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
|
||||
for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do
|
||||
NAME=$(docker inspect --format '{{.Name}}' "$C")
|
||||
[ "$NAME" = "/lpk-libredecision-app" ] && continue
|
||||
echo "Retrait de $NAME de la racine (revendique $TAG)"
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' "$C")" = "true" ]; then
|
||||
echo "$C" >> "$RETIRES"
|
||||
fi
|
||||
docker update --restart=no "$C" >/dev/null
|
||||
docker stop "$C" >/dev/null
|
||||
done
|
||||
@@ -136,7 +144,7 @@ steps:
|
||||
exit 1
|
||||
fi
|
||||
# Image construite localement dans la pipeline : pas de docker compose pull
|
||||
- cd /opt/libredecision-app && docker compose up -d --remove-orphans
|
||||
- cd /opt/libredecision-app && docker compose up -d --remove-orphans --force-recreate
|
||||
- cd /opt/libredecision-app && docker compose ps
|
||||
|
||||
- name: test-deploy
|
||||
@@ -153,10 +161,13 @@ steps:
|
||||
echo "PASS: conteneur running"
|
||||
|
||||
# Le produit répond, et c'est bien ce commit qui répond.
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
- name: healthcheck
|
||||
image: alpine:3.20
|
||||
depends_on:
|
||||
- test-deploy
|
||||
volumes:
|
||||
- /opt/libredecision-app:/opt/libredecision-app
|
||||
commands:
|
||||
- apk add --no-cache --quiet curl
|
||||
- |
|
||||
@@ -169,7 +180,9 @@ steps:
|
||||
echo "Tentative $((i+1))/$MAX - $BODY"
|
||||
case "$BODY" in
|
||||
*'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*)
|
||||
echo "PASS: $BUILD repond sur $TARGET"; exit 0 ;;
|
||||
echo "PASS: $BUILD repond sur $TARGET"
|
||||
: > /opt/libredecision-app/retires
|
||||
exit 0 ;;
|
||||
esac
|
||||
i=$((i+1))
|
||||
sleep 10
|
||||
@@ -177,8 +190,39 @@ steps:
|
||||
echo "FAIL: $BUILD ne repond pas apres 10 minutes"
|
||||
exit 1
|
||||
|
||||
# Échec après le retrait de la racine : le produit s'arrête, ce qu'il avait retiré
|
||||
# repart (restart: always rendu). Échec avant (construction, tests) : rien à faire,
|
||||
# la liste est vide depuis le dernier déploiement réussi.
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
- name: retablir
|
||||
image: docker:27-cli
|
||||
# En graphe (depends_on), une étape sans dépendance démarrerait au début : celle-ci
|
||||
# attend la fin du healthcheck (fait, échoué ou sauté).
|
||||
depends_on:
|
||||
- healthcheck
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/libredecision-app:/opt/libredecision-app
|
||||
commands:
|
||||
- |
|
||||
RETIRES=/opt/libredecision-app/retires
|
||||
if [ ! -s "$RETIRES" ]; then echo "Rien a retablir"; exit 0; fi
|
||||
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
|
||||
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
|
||||
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
|
||||
docker stop lpk-libredecision-app >/dev/null 2>&1 || true
|
||||
for C in $(cat "$RETIRES"); do
|
||||
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
|
||||
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
|
||||
done
|
||||
: > "$RETIRES"
|
||||
when:
|
||||
- status: failure
|
||||
|
||||
- name: notify-failure
|
||||
image: alpine:3.20
|
||||
depends_on:
|
||||
- retablir
|
||||
commands:
|
||||
- 'echo "ECHEC pipeline app #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
||||
when:
|
||||
|
||||
@@ -71,7 +71,8 @@ steps:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
|
||||
- mkdir -p .reports
|
||||
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
||||
- syft libredecision-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! tells two kinds of corrections apart:
|
||||
//!
|
||||
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
|
||||
//! two misspelt words fixed (edit distance ≤ 2, no number, no negation
|
||||
//! touched). The votes stay; the correction is shown with its diff.
|
||||
//! two misspelt words fixed (edit distance ≤ 2, no negation touched) — and
|
||||
//! never a figure, a sign, a separator inside a number or a relation
|
||||
//! (`≥`, `+`, `%`…). The votes stay; the correction is shown with its diff.
|
||||
//! - **substantive**: anything else. It opens a new revision and every vote
|
||||
//! cast on the previous one waits for its author to reconfirm it.
|
||||
//!
|
||||
@@ -48,9 +49,32 @@ fn skeleton(s: &str) -> String {
|
||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// The numbers of a text, as written: "50" and "5,0", "1 5" and "15" differ.
|
||||
fn numbers(s: &str) -> Vec<&str> {
|
||||
s.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).collect()
|
||||
/// Symbols that carry a value or a relation, wherever they stand.
|
||||
const RELATIONS: &[char] =
|
||||
&['+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√'];
|
||||
/// Signs and fraction bars: meaningful next to a figure ("-5", "1/5", "5-10").
|
||||
const SIGNS: &[char] = &['-', '−', '–', '/'];
|
||||
/// Separators: meaningful between two figures ("10 000", "10,000", "182.625").
|
||||
const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’'];
|
||||
|
||||
/// What the text says in figures and symbols, as written. Two texts with the
|
||||
/// same letters but a different figure, sign, separator inside a number or
|
||||
/// relation ("≥" → "≤", "+" → "-", "50" → "5,0", "182.625" → "182 625") do not
|
||||
/// say the same thing.
|
||||
fn figures(s: &str) -> String {
|
||||
let cs: Vec<char> = s.chars().collect();
|
||||
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
|
||||
cs.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, c)| {
|
||||
let (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1)));
|
||||
c.is_numeric()
|
||||
|| RELATIONS.contains(c)
|
||||
|| (SIGNS.contains(c) && (before || after))
|
||||
|| (SEPARATORS.contains(c) && before && after)
|
||||
})
|
||||
.map(|(_, c)| *c)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Folded words (runs of letters/digits).
|
||||
@@ -62,8 +86,8 @@ pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
if old == new {
|
||||
return EditKind::Unchanged;
|
||||
}
|
||||
// A number is never a typo: punctuation or a space inside it changes its value.
|
||||
if numbers(old) != numbers(new) {
|
||||
// A figure, a sign or a relation is never a typo: it changes what the text says.
|
||||
if figures(old) != figures(new) {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
if skeleton(old) == skeleton(new) {
|
||||
@@ -208,11 +232,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_is_never_a_typo() {
|
||||
// Same letters and digits, different values (independent review, 2026-10-21).
|
||||
assert_eq!(classify("au moins 50 voix", "au moins 5,0 voix"), EditKind::Substantive);
|
||||
assert_eq!(classify("1 5 jours", "15 jours"), EditKind::Substantive);
|
||||
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
|
||||
// Same letters, different meaning (independent reviews, 2026-10-21).
|
||||
for (old, new) in [
|
||||
("au moins 50 voix", "au moins 5,0 voix"),
|
||||
("1 5 jours", "15 jours"),
|
||||
("10 000 membres", "10,000 membres"),
|
||||
("un écart de -5", "un écart de 5"),
|
||||
("1/5 des voix", "1.5 des voix"),
|
||||
("50 % des voix", "50 ‰ des voix"),
|
||||
("avoir >= 5 certifications", "avoir <= 5 certifications"),
|
||||
("DU(n) + c² × M(n)", "DU(n) - c² × M(n)"),
|
||||
("182.625 jours", "182 625 jours"),
|
||||
("c²", "c2"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
// Punctuation around figures, list markers, dashes between words stay typographic.
|
||||
assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic);
|
||||
assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic);
|
||||
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Typographic);
|
||||
assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,6 +28,17 @@ use trustin_access::{Identity, IdentityKind};
|
||||
|
||||
pub const SESSION_COOKIE: &str = "ld_session";
|
||||
const OAUTH_COOKIE: &str = "ld_oauth";
|
||||
|
||||
/// Over https, the prefixed names: a neighbouring site under the same parent
|
||||
/// domain cannot plant a `__Host-` cookie (session fixation), nor a
|
||||
/// `__Secure-` one without https.
|
||||
fn session_cookie(state: &AppState) -> &'static str {
|
||||
if state.cfg.secure_cookies() { "__Host-ld_session" } else { SESSION_COOKIE }
|
||||
}
|
||||
|
||||
fn oauth_cookie(state: &AppState) -> &'static str {
|
||||
if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE }
|
||||
}
|
||||
const SESSION_DAYS: i64 = 30;
|
||||
const INVITATION_DAYS: i64 = 14;
|
||||
|
||||
@@ -83,7 +94,7 @@ impl FromRequestParts<AppState> for Viewer {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let Some(token) = cookie_value(&parts.headers, SESSION_COOKIE) else {
|
||||
let Some(token) = cookie_value(&parts.headers, session_cookie(state)) else {
|
||||
return Ok(Self(None));
|
||||
};
|
||||
let row = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||
@@ -124,7 +135,7 @@ async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderVa
|
||||
.bind(identity_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(cookie(state, SESSION_COOKIE, &token, "/", SESSION_DAYS * 86_400))
|
||||
Ok(cookie(state, session_cookie(state), &token, "/", SESSION_DAYS * 86_400))
|
||||
}
|
||||
|
||||
pub async fn load_identity(state: &AppState, id: &str) -> AppResult<IdentityRow> {
|
||||
@@ -212,7 +223,7 @@ pub async fn g1_start(State(state): State<AppState>, Query(q): Query<StartQuery>
|
||||
.append_pair("redirect_uri", &state.cfg.connect_callback())
|
||||
.append_pair("state", &csrf);
|
||||
let stash = format!("{csrf}.{}", url::form_urlencoded::byte_serialize(back.as_bytes()).collect::<String>());
|
||||
Ok(redirect(url.as_str(), vec![cookie(&state, OAUTH_COOKIE, &stash, "/api/auth/g1", 600)]))
|
||||
Ok(redirect(url.as_str(), vec![cookie(&state, oauth_cookie(&state), &stash, "/api/auth/g1", 600)]))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -243,8 +254,8 @@ pub async fn g1_callback(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<CallbackQuery>,
|
||||
) -> Response {
|
||||
let clear = cookie(&state, OAUTH_COOKIE, "", "/api/auth/g1", 0);
|
||||
let stash = cookie_value(&headers, OAUTH_COOKIE).unwrap_or_default();
|
||||
let clear = cookie(&state, oauth_cookie(&state), "", "/api/auth/g1", 0);
|
||||
let stash = cookie_value(&headers, oauth_cookie(&state)).unwrap_or_default();
|
||||
let (expected, back) = stash.split_once('.').unwrap_or(("", ""));
|
||||
let back =
|
||||
safe_return(url::form_urlencoded::parse(format!("p={back}").as_bytes()).next().map(|(_, v)| v).as_deref());
|
||||
@@ -460,14 +471,14 @@ pub async fn dev_login(State(state): State<AppState>, Json(body): Json<DevBody>)
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||
if let Some(token) = cookie_value(&headers, SESSION_COOKIE) {
|
||||
if let Some(token) = cookie_value(&headers, session_cookie(&state)) {
|
||||
sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
|
||||
.bind(util::sha256_hex(&token))
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
let mut res = Json(json!({ "me": null })).into_response();
|
||||
res.headers_mut().append(SET_COOKIE, cookie(&state, SESSION_COOKIE, "", "/", 0));
|
||||
res.headers_mut().append(SET_COOKIE, cookie(&state, session_cookie(&state), "", "/", 0));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -522,6 +533,8 @@ mod tests {
|
||||
assert_eq!(safe_return(Some("//evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("https://evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\\evil")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes");
|
||||
assert_eq!(safe_return(None), "/groupes");
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,18 @@ async fn editing_is_not_voting() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
assert_eq!(v["error"], "substantive_in_force");
|
||||
// Flipping a relation in a seed text is not a typo, even with the same letters.
|
||||
let k1 = unit(&doc, "K1")["proposals"][0].clone();
|
||||
let flipped = k1["body"].as_str().expect("body").replace(">= 5 certifications", "<= 5 certifications");
|
||||
let (s, v) = b
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{}", k1["id"].as_str().expect("id")),
|
||||
Some(json!({ "title": k1["title"], "body": flipped })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
||||
assert_eq!(v["error"], "substantive_not_author");
|
||||
|
||||
// A member proposal: typo keeps votes, substance asks to reconfirm.
|
||||
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté.
|
||||
|
||||
**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`) :
|
||||
- **jamais coquille** si un nombre change, même par la ponctuation (« 50 » → « 5,0 ») ;
|
||||
- **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre ou une relation
|
||||
change (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - ») ;
|
||||
- **coquille** si le squelette (sans accents, casse, ponctuation, espaces) est identique, ou si
|
||||
au plus 2 mots diffèrent de 2 lettres au plus, sans chiffre ni mot de négation (ne, pas,
|
||||
jamais, personne…) ; elle s'applique sans vote, garde les votes et les traductions ;
|
||||
|
||||
@@ -80,9 +80,9 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p
|
||||
| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent |
|
||||
| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version |
|
||||
|
||||
Le classement est prudent : un nombre changé (même par une virgule ou une espace : « 50 » →
|
||||
« 5,0 ») ou un mot de négation touché (ne, pas, jamais, personne…) rend la modification « de
|
||||
fond ». Moteur : `crates/ld-engine/src/edit.rs`.
|
||||
Le classement est prudent : un chiffre, un signe, un séparateur dans un nombre ou une relation
|
||||
changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - », « % »)
|
||||
ou un mot de négation touché (ne, pas, jamais, personne…) rendent la modification « de fond ». Moteur : `crates/ld-engine/src/edit.rs`.
|
||||
|
||||
### Deux articles qui se contredisent
|
||||
|
||||
@@ -145,6 +145,15 @@ sans toucher au texte de la communauté. Chiara et Yvv en sont membres.
|
||||
|
||||
Les raisons longues sont dans `docs/adr/`.
|
||||
|
||||
## Limites connues (assumées pour ce premier chantier)
|
||||
|
||||
- Le droit de vote se vérifie **au moment du vote** : un membre sorti de la toile, ou dont
|
||||
l'accès est révoqué, garde les voix déjà exprimées.
|
||||
- Dans un groupe géré (essai, Axiom…), une même personne admise sous une identité d'invitation
|
||||
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
|
||||
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
|
||||
(pour la dépanner) — jamais pour une identité Ğ1.
|
||||
|
||||
## Questions ouvertes (à Yvv)
|
||||
|
||||
- **Licence** du code — non choisie.
|
||||
|
||||
@@ -23,8 +23,9 @@ ce navigateur pour 30 jours.
|
||||
|
||||
1. Ouvre ton lien d'invitation → tu arrives connectée.
|
||||
2. Les contrôles qui écrivent (voter, corriger, découper) se font dans le **groupe d'essai** :
|
||||
il est caché, une seule voix « pour » suffit à faire passer une proposition, et rien n'en
|
||||
sort. Le texte de Monnaie libre, lui, est le vrai : avec ton lien d'invitation tu le lis,
|
||||
il est caché, son plancher est d'une voix, et rien n'en sort. À deux membres (Yvv et toi),
|
||||
une proposition passe avec vos deux voix « pour » — la formule demande la majorité de
|
||||
l'électorat quand il est si petit. Le texte de Monnaie libre, lui, est le vrai : avec ton lien d'invitation tu le lis,
|
||||
tu proposes, tu signales ; le vote y est réservé aux membres de la toile (avec ta clé Ğ1).
|
||||
3. Passe les contrôles C1–C11 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne.
|
||||
4. Chaque remarque : **« Faire un retour »** dans le menu de gauche. Écris ce que tu as fait, ce
|
||||
|
||||
@@ -310,6 +310,7 @@ export const en: Messages = {
|
||||
'error.last_admin': 'At least one administrator must remain.',
|
||||
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
|
||||
'error.invitation_invalid': 'This invitation link is no longer valid.',
|
||||
'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
|
||||
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
|
||||
'error.retry': 'Try again',
|
||||
|
||||
|
||||
@@ -310,6 +310,7 @@ export const es: Messages = {
|
||||
'error.last_admin': 'Debe quedar al menos un administrador.',
|
||||
'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.',
|
||||
'error.invitation_invalid': 'Este enlace de invitación ya no es válido.',
|
||||
'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
|
||||
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
|
||||
'error.retry': 'Reintentar',
|
||||
|
||||
|
||||
@@ -313,6 +313,7 @@ export const fr = {
|
||||
'error.last_admin': 'Il faut garder au moins un administrateur.',
|
||||
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
|
||||
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.",
|
||||
'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
|
||||
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
|
||||
'error.retry': 'Réessayer',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user