Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible
Test / test (pull_request) Successful in 51s
Test / test (pull_request) Successful in 51s
Every person a page names as responsible gets a Kanidm account at start if they have none, and a mail saying they are listed as responsible for asking that question, with the link to sign in; RESPONSIBLE_GROUP puts them in one group. SEED_ADMIN_EMAIL invites the first person into the most privileged group - the one whose desk may invite into the most - once. Signed in, a responsible person may suggest a change to their own pages; checked on the server, kept, published, and mailed to the site's inbox. Fixes an invite of someone new on a Kanidm with only the onboarding account: the person is created with their address in one call, since idm_people_on_boarding may create but not modify (it answered 403). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bd174224fe
commit
0886869607
@@ -4,6 +4,12 @@ One line per change, grouped by the release that shipped it. Newest
|
|||||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||||
content repos pin the tag they run (see the end of this file).
|
content repos pin the tag they run (see the end of this file).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Accounts the content mandates, made at start (`src/seed.rs`). Every person a page names as `responsible` gets a Kanidm account if they have none, and a mail in the site's language saying they are listed as responsible for asking that question, with the link to sign in; `RESPONSIBLE_GROUP` puts them all in one group (people who already had an account are added without a mail). `SEED_ADMIN_EMAIL` (and `SEED_ADMIN_NAME`, `SEED_GROUP`) invites the first person into the site's most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (`portal_seed`), and retry while Kanidm comes up.
|
||||||
|
- A page's responsible person, signed in, may suggest a change to it: a card at the foot of their own pages, checked on the server against the page's `responsible.contact`, kept in `portal_edit_suggestions`, published on `portal.edits.suggested`, and mailed to the site's inbox with them as reply-to. The session now carries the signed-in person's email.
|
||||||
|
- Fixed: an invite of someone new failed on a Kanidm set up with the onboarding account alone. Portal created the person, then set their address in a second call, and `idm_people_on_boarding` may create but not modify: 403. The address now goes in with the person.
|
||||||
|
|
||||||
## 0.5.3 (2026-09-29)
|
## 0.5.3 (2026-09-29)
|
||||||
|
|
||||||
- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot ("Tilbake til starten", "Back to “What came in?”"). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a "back" alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
|
- A way down the tree from every page: an arrow fixed at the left edge, half-way up, and the same link in words at the foot ("Tilbake til starten", "Back to “What came in?”"). It goes to the nearest page below the current one that the visitor may open, taking the path apart a segment at a time, and to the front door when none is; the front door has none. Content no longer needs a "back" alternative, and a visitor who went up a branch that turned out not to apply is never stranded.
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ values.
|
|||||||
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
|
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
|
||||||
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
|
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
|
||||||
| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens |
|
| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens |
|
||||||
|
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) |
|
||||||
|
| `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins; they get an account at start either way |
|
||||||
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
|
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
|
||||||
| `GITEA_API_TOKEN` | authenticated resource pulls |
|
| `GITEA_API_TOKEN` | authenticated resource pulls |
|
||||||
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
|
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
|
||||||
|
|||||||
+110
@@ -456,6 +456,13 @@ fn QuestionView(
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
// The person the page names as responsible, signed in, may
|
||||||
|
// suggest a change to it; the server checks it is them.
|
||||||
|
{question
|
||||||
|
.responsible
|
||||||
|
.as_ref()
|
||||||
|
.filter(|r| is_responsible(user.as_ref(), &r.contact))
|
||||||
|
.map(|_| view! { <SuggestEdit question_id=question.id.clone()/> })}
|
||||||
{question
|
{question
|
||||||
.responsible
|
.responsible
|
||||||
.clone()
|
.clone()
|
||||||
@@ -618,6 +625,47 @@ fn Announcements(current_id: String) -> impl IntoView {
|
|||||||
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
|
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
|
||||||
/// out of what they see. Not a defense against a targeted scrape, just
|
/// out of what they see. Not a defense against a targeted scrape, just
|
||||||
/// against the generic bot crawl.
|
/// against the generic bot crawl.
|
||||||
|
/// Whether the signed-in person is the one a page names as responsible:
|
||||||
|
/// their Kanidm address equals the page's `responsible.contact`
|
||||||
|
/// (`mailto:` off, case ignored).
|
||||||
|
pub fn is_responsible(user: Option<&User>, contact: &str) -> bool {
|
||||||
|
let contact = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||||
|
user.and_then(|u| u.email.as_deref()).is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change suggested by the person responsible for the page: one
|
||||||
|
/// field, sent to the site's inbox. Shown only to them.
|
||||||
|
#[component]
|
||||||
|
fn SuggestEdit(question_id: String) -> impl IntoView {
|
||||||
|
let send = ServerAction::<SuggestPageEdit>::new();
|
||||||
|
let text = NodeRef::<leptos::html::Textarea>::new();
|
||||||
|
let sent = move || send.value().get().is_some_and(|r| r.is_ok());
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
let value = text.get().map(|t| t.value()).unwrap_or_default();
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
send.dispatch(SuggestPageEdit { question_id: question_id.clone(), text: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<section class="suggest-edit">
|
||||||
|
<h2>{move || t(&lang().get(), "suggest_edit")}</h2>
|
||||||
|
<Show
|
||||||
|
when=sent
|
||||||
|
fallback=move || view! {
|
||||||
|
<p>{move || t(&lang().get(), "suggest_edit_help")}</p>
|
||||||
|
<form on:submit=on_submit.clone()>
|
||||||
|
<textarea node_ref=text rows="4" required=true></textarea>
|
||||||
|
<button type="submit" disabled=move || send.pending().get()>{move || t(&lang().get(), "suggest_edit_send")}</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>{move || t(&lang().get(), "suggest_edit_sent")}</p>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
|
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
|
||||||
let (user, domain) = responsible
|
let (user, domain) = responsible
|
||||||
@@ -2433,6 +2481,49 @@ pub struct SubmitResult {
|
|||||||
pub chain_hash: String,
|
pub chain_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A change to a page, suggested by the person it names as responsible.
|
||||||
|
/// Checked here, not trusted from the page: the session's address must
|
||||||
|
/// be the page's responsible contact. Kept in `portal_edit_suggestions`,
|
||||||
|
/// announced on `portal.edits.suggested`, and mailed to the site's inbox.
|
||||||
|
#[server(endpoint = "suggest_page_edit")]
|
||||||
|
pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), ServerFnError> {
|
||||||
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
|
use crate::server::AppState;
|
||||||
|
|
||||||
|
let state = expect_context::<AppState>();
|
||||||
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||||
|
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let text = text.trim().to_string();
|
||||||
|
if text.is_empty() || text.len() > 8000 {
|
||||||
|
return Err(ServerFnError::new("a suggestion is between one and 8000 characters"));
|
||||||
|
}
|
||||||
|
let question = state.questions.load().get(&question_id).cloned().ok_or_else(|| ServerFnError::new("no such page"))?;
|
||||||
|
let responsible = question.responsible.clone().ok_or_else(|| ServerFnError::new("this page names no one responsible"))?;
|
||||||
|
if !is_responsible(user.as_ref(), &responsible.contact) {
|
||||||
|
return Err(ServerFnError::new("only the person responsible for this page may suggest changes to it here"));
|
||||||
|
}
|
||||||
|
let user = user.expect("is_responsible needs a user");
|
||||||
|
let email = user.email.clone().unwrap_or_default();
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
let record = serde_json::json!({
|
||||||
|
"question_id": question_id, "question": question.name,
|
||||||
|
"by": user.username, "email": email, "text": text, "submitted_ms": now_ms,
|
||||||
|
});
|
||||||
|
let store = match state.jetstream.get_key_value("portal_edit_suggestions").await {
|
||||||
|
Ok(store) => store,
|
||||||
|
Err(_) => state
|
||||||
|
.jetstream
|
||||||
|
.create_key_value(async_nats::jetstream::kv::Config { bucket: "portal_edit_suggestions".to_string(), ..Default::default() })
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?,
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec(&record).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
store.put(format!("{now_ms}-{}", user.username.replace(|c: char| !c.is_ascii_alphanumeric(), "_")), bytes.clone().into()).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
|
||||||
|
crate::mail::on_edit_suggestion(&state, &question_id, &question.name, &user.display_name, &email, &text).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[server(endpoint = "submit_answer")]
|
#[server(endpoint = "submit_answer")]
|
||||||
pub async fn submit_answer(
|
pub async fn submit_answer(
|
||||||
question_id: String,
|
question_id: String,
|
||||||
@@ -2681,3 +2772,22 @@ mod down_the_tree_tests {
|
|||||||
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
|
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod responsible_tests {
|
||||||
|
use super::is_responsible;
|
||||||
|
use crate::auth::User;
|
||||||
|
|
||||||
|
fn user(email: Option<&str>) -> User {
|
||||||
|
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], email: email.map(String::from) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_person_a_page_names_may_suggest_a_change_to_it() {
|
||||||
|
assert!(is_responsible(Some(&user(Some("kari@vel.no"))), "mailto:Kari@Vel.no"));
|
||||||
|
assert!(!is_responsible(Some(&user(Some("ola@vel.no"))), "kari@vel.no"));
|
||||||
|
assert!(!is_responsible(Some(&user(None)), "kari@vel.no"));
|
||||||
|
assert!(!is_responsible(None, "kari@vel.no"));
|
||||||
|
assert!(!is_responsible(Some(&user(Some(""))), ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub struct User {
|
|||||||
/// live, so a group change only takes effect on the next login.
|
/// live, so a group change only takes effect on the next login.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub groups: Vec<String>,
|
pub groups: Vec<String>,
|
||||||
|
/// The address Kanidm holds for them (the `email` claim), so a page
|
||||||
|
/// can know it is talking to the person it names as responsible.
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const SESSION_USER_KEY: &str = "user";
|
pub const SESSION_USER_KEY: &str = "user";
|
||||||
|
|||||||
+12
@@ -34,6 +34,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
("en", "announcements") => "Announcements",
|
("en", "announcements") => "Announcements",
|
||||||
("en", "down_to") => "Back to ",
|
("en", "down_to") => "Back to ",
|
||||||
|
("en", "suggest_edit") => "Suggest a change to this page",
|
||||||
|
("en", "suggest_edit_help") => "You are listed as responsible for this question. Say what should change, and it goes to the people who keep the site.",
|
||||||
|
("en", "suggest_edit_send") => "Send the suggestion",
|
||||||
|
("en", "suggest_edit_sent") => "Thank you - your suggestion is sent.",
|
||||||
("en", "the_start") => "the start",
|
("en", "the_start") => "the start",
|
||||||
|
|
||||||
("no", "also_worth_asking") => "Også verdt å spørre",
|
("no", "also_worth_asking") => "Også verdt å spørre",
|
||||||
@@ -54,6 +58,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
("no", "announcements") => "Kunngjøringer",
|
("no", "announcements") => "Kunngjøringer",
|
||||||
("no", "down_to") => "Tilbake til ",
|
("no", "down_to") => "Tilbake til ",
|
||||||
|
("no", "suggest_edit") => "Foreslå en endring på denne siden",
|
||||||
|
("no", "suggest_edit_help") => "Du står oppført som ansvarlig for dette spørsmålet. Skriv hva som bør endres, så går det til dem som har siden.",
|
||||||
|
("no", "suggest_edit_send") => "Send forslaget",
|
||||||
|
("no", "suggest_edit_sent") => "Takk - forslaget er sendt.",
|
||||||
("no", "the_start") => "starten",
|
("no", "the_start") => "starten",
|
||||||
|
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -89,6 +97,10 @@ mod tests {
|
|||||||
"announcements",
|
"announcements",
|
||||||
"down_to",
|
"down_to",
|
||||||
"the_start",
|
"the_start",
|
||||||
|
"suggest_edit",
|
||||||
|
"suggest_edit_help",
|
||||||
|
"suggest_edit_send",
|
||||||
|
"suggest_edit_sent",
|
||||||
] {
|
] {
|
||||||
assert!(lookup("en", key).is_some(), "en missing {key}");
|
assert!(lookup("en", key).is_some(), "en missing {key}");
|
||||||
assert!(lookup("no", key).is_some(), "no missing {key}");
|
assert!(lookup("no", key).is_some(), "no missing {key}");
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ pub mod people;
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod mail;
|
pub mod mail;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod seed;
|
||||||
|
|
||||||
pub mod maps;
|
pub mod maps;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|||||||
+93
@@ -301,6 +301,99 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mail a person gets when the content names them responsible for
|
||||||
|
/// a question and they had no account: who they are to the site (the
|
||||||
|
/// person listed as asking that question), and the link that lets them
|
||||||
|
/// sign in and suggest changes to it. Built in, in the site's language.
|
||||||
|
pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) {
|
||||||
|
let site = state.site.load();
|
||||||
|
let Some(cfg) = &site.mail else {
|
||||||
|
tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||||
|
let quoted: Vec<String> = questions.iter().map(|q| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") }).collect();
|
||||||
|
let list = match quoted.len() {
|
||||||
|
0 => String::new(),
|
||||||
|
1 => quoted[0].clone(),
|
||||||
|
n => format!("{} {} {}", quoted[..n - 1].join(", "), if norwegian { "og" } else { "and" }, quoted[n - 1]),
|
||||||
|
};
|
||||||
|
let (subject, body) = if norwegian {
|
||||||
|
(
|
||||||
|
"Du står som ansvarlig for et spørsmål hos {site_name}".to_string(),
|
||||||
|
"Hei {name},\n\nDu står oppført som ansvarlig for å stille {questions} hos {site_name}. Derfor har du fått en konto der, så du kan logge inn og foreslå endringer i det du står bak.\n\nVelg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.".to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"You are listed as responsible for a question at {site_name}".to_string(),
|
||||||
|
"Hello {name},\n\nYou are listed as responsible for asking {questions} at {site_name}. So you now have an account there, to sign in and suggest changes to what you stand behind.\n\nSet your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}. Afterwards you sign in at {site}.".to_string(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
let expires = invited
|
||||||
|
.link_expires_ms
|
||||||
|
.and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
|
||||||
|
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut vars = serde_json::Map::new();
|
||||||
|
vars.insert("name".into(), invited.display_name.clone().into());
|
||||||
|
vars.insert("username".into(), invited.username.clone().into());
|
||||||
|
vars.insert("questions".into(), list.into());
|
||||||
|
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||||
|
vars.insert("site".into(), public_url().into());
|
||||||
|
vars.insert("site_name".into(), site_name(&site).into());
|
||||||
|
vars.insert("expires".into(), expires.into());
|
||||||
|
let text = render(&body, &vars);
|
||||||
|
let mail = Outgoing {
|
||||||
|
v: CONTRACT,
|
||||||
|
id: format!("responsible:{}:{now_ms}", invited.username),
|
||||||
|
site: site_name(&site),
|
||||||
|
from: cfg.from.clone(),
|
||||||
|
reply_to: cfg.reply_to.clone(),
|
||||||
|
to: invited.email.clone(),
|
||||||
|
subject: render(&subject, &vars),
|
||||||
|
html: to_html(&text),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||||
|
tracing::error!(user = %invited.username, "publishing the responsible mail failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change suggested by the person responsible for a page, to the
|
||||||
|
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
|
||||||
|
/// suggester as reply-to so answering the mail answers them.
|
||||||
|
pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) {
|
||||||
|
let site = state.site.load();
|
||||||
|
let Some(cfg) = &site.mail else {
|
||||||
|
tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone());
|
||||||
|
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||||
|
let (subject, lead) = if norwegian {
|
||||||
|
(format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url()))
|
||||||
|
} else {
|
||||||
|
(format!("Suggested change: \u{201c}{question_name}\u{201d}"), format!("{by_name} ({by_email}), listed as responsible for the question, suggests a change to {}{question_id}:", public_url()))
|
||||||
|
};
|
||||||
|
let body = format!("{lead}\n\n{text}");
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
let mail = Outgoing {
|
||||||
|
v: CONTRACT,
|
||||||
|
id: format!("suggestion:{question_id}:{now_ms}"),
|
||||||
|
site: site_name(&site),
|
||||||
|
from: cfg.from.clone(),
|
||||||
|
reply_to: Some(by_email.to_string()),
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html: to_html(&body),
|
||||||
|
text: body,
|
||||||
|
};
|
||||||
|
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||||
|
tracing::error!(question_id, "publishing the suggestion mail failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The invite mail: the one-time link to the person just onboarded.
|
/// The invite mail: the one-time link to the person just onboarded.
|
||||||
/// Built in, since portal knows everything about it; `mail.invite` in
|
/// Built in, since portal knows everything about it; `mail.invite` in
|
||||||
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
|
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// windows (see announce.rs).
|
// windows (see announce.rs).
|
||||||
tokio::spawn(portal::announce::run(state.clone()));
|
tokio::spawn(portal::announce::run(state.clone()));
|
||||||
tokio::spawn(portal::deadlines::run(state.clone()));
|
tokio::spawn(portal::deadlines::run(state.clone()));
|
||||||
|
// The first person in, when SEED_ADMIN_EMAIL names one (seed.rs).
|
||||||
|
tokio::spawn(portal::seed::run(state.clone()));
|
||||||
|
|
||||||
// Replies to case mail, matched to their record by whatever reads
|
// Replies to case mail, matched to their record by whatever reads
|
||||||
// the mailbox (uhhm/gdo over JMAP) and turned into notes here. A
|
// the mailbox (uhhm/gdo over JMAP) and turned into notes here. A
|
||||||
|
|||||||
+29
-13
@@ -109,34 +109,50 @@ impl Kanidm {
|
|||||||
/// username, is only added to the group; their credentials are
|
/// username, is only added to the group; their credentials are
|
||||||
/// left alone and no link is made.
|
/// left alone and no link is made.
|
||||||
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
|
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
|
||||||
|
self.onboard(name, email, Some(group)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An account and nothing more: for a person the content names as
|
||||||
|
/// responsible for a page, who needs to be able to sign in whether
|
||||||
|
/// or not they sit at any desk. `group` is empty in what comes back.
|
||||||
|
pub async fn account(&self, name: &str, email: &str) -> anyhow::Result<Invited> {
|
||||||
|
self.onboard(name, email, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether Kanidm already holds a person with this address.
|
||||||
|
pub async fn has_person_with_mail(&self, email: &str) -> anyhow::Result<bool> {
|
||||||
|
Ok(self.find_by_mail(email).await?.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result<Invited> {
|
||||||
let existing = self.find_by_mail(email).await?;
|
let existing = self.find_by_mail(email).await?;
|
||||||
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
||||||
let created = if existing.is_some() || self.person_exists(&username).await? {
|
let created = if existing.is_some() || self.person_exists(&username).await? {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
|
// The address goes in with the person, in one call: the
|
||||||
|
// onboarding account may create a person but not modify one
|
||||||
|
// (idm_people_on_boarding), so a separate PUT of `mail`
|
||||||
|
// afterwards is refused with 403 and the invite fails.
|
||||||
let (status, text) = self
|
let (status, text) = self
|
||||||
.call(
|
.call(
|
||||||
openidconnect::reqwest::Method::POST,
|
openidconnect::reqwest::Method::POST,
|
||||||
"/v1/person",
|
"/v1/person",
|
||||||
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name] } })),
|
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name], "mail": [email] } })),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if !(200..300).contains(&status) {
|
if !(200..300).contains(&status) {
|
||||||
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
|
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
|
||||||
}
|
}
|
||||||
let (status, text) = self
|
|
||||||
.call(openidconnect::reqwest::Method::PUT, &format!("/v1/person/{username}/_attr/mail"), Some(serde_json::json!([email])))
|
|
||||||
.await?;
|
|
||||||
if !(200..300).contains(&status) {
|
|
||||||
anyhow::bail!("kanidm: setting mail on {username}: http {status}: {text}");
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
};
|
};
|
||||||
let (status, text) = self
|
if let Some(group) = group {
|
||||||
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
let (status, text) = self
|
||||||
.await?;
|
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
||||||
if !(200..300).contains(&status) {
|
.await?;
|
||||||
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
|
if !(200..300).contains(&status) {
|
||||||
|
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let (reset_link, link_expires_ms) = if created {
|
let (reset_link, link_expires_ms) = if created {
|
||||||
// 24 hours: the longest Kanidm allows, since the link travels
|
// 24 hours: the longest Kanidm allows, since the link travels
|
||||||
@@ -163,7 +179,7 @@ impl Kanidm {
|
|||||||
username,
|
username,
|
||||||
display_name: name.to_string(),
|
display_name: name.to_string(),
|
||||||
email: email.to_string(),
|
email: email.to_string(),
|
||||||
group: group.to_string(),
|
group: group.unwrap_or_default().to_string(),
|
||||||
reset_link,
|
reset_link,
|
||||||
link_expires_ms,
|
link_expires_ms,
|
||||||
created,
|
created,
|
||||||
|
|||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
//! Accounts the content mandates, made at start.
|
||||||
|
//!
|
||||||
|
//! **The first person in.** A site with its own Kanidm starts with nobody
|
||||||
|
//! on any desk, and every later person is invited from a desk - so the
|
||||||
|
//! first one has to come from somewhere else. `SEED_ADMIN_EMAIL` names
|
||||||
|
//! them: at start, portal invites that address into the site's most
|
||||||
|
//! privileged group and mails them the one-time link, exactly as a desk
|
||||||
|
//! invite would. From there they invite everyone else themselves.
|
||||||
|
//!
|
||||||
|
//! - `SEED_ADMIN_EMAIL` - who; unset, nothing happens.
|
||||||
|
//! - `SEED_ADMIN_NAME` - their display name; defaults to the address's
|
||||||
|
//! local part.
|
||||||
|
//! - `SEED_GROUP` - the group; defaults to the one whose desk may invite
|
||||||
|
//! into the most groups (the board, on a vel), read from the content.
|
||||||
|
//!
|
||||||
|
//! Once, not on every start: a done seed is remembered in the
|
||||||
|
//! `portal_seed` bucket, keyed by address and group, so a restart or an
|
||||||
|
//! upgrade never mails a second link. Changing `SEED_ADMIN_EMAIL` or
|
||||||
|
//! `SEED_GROUP` seeds the new pair. Kanidm and gdo may still be coming
|
||||||
|
//! up alongside portal, so a failed attempt is retried for a while.
|
||||||
|
//!
|
||||||
|
//! **Everyone the content names as responsible.** A page's
|
||||||
|
//! `responsible: { name, contact }` is a person answering for that
|
||||||
|
//! question, so they get an account: at every start, each responsible
|
||||||
|
//! address Kanidm does not already hold is given one, and mailed that
|
||||||
|
//! they are listed as responsible for asking those questions, with the
|
||||||
|
//! link to sign in. Signed in, their own pages offer them a way to
|
||||||
|
//! suggest changes. `RESPONSIBLE_GROUP` names a group they all join -
|
||||||
|
//! on the vel, `redaktor`, which is also who may sign in to the project
|
||||||
|
//! Gitea - and people who already had an account are added to it
|
||||||
|
//! without a mail. A desk is still a desk invite. Each address is
|
||||||
|
//! onboarded once per group (`portal_seed`).
|
||||||
|
#![cfg(feature = "ssr")]
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
|
|
||||||
|
use crate::content::Question;
|
||||||
|
|
||||||
|
pub const SEED_BUCKET: &str = "portal_seed";
|
||||||
|
|
||||||
|
/// The group whose members may invite into the most groups: a page
|
||||||
|
/// gated to group G carrying an `invite: { group: X }` alternative
|
||||||
|
/// means G may bring people into X. Ties go to the name that sorts
|
||||||
|
/// first, so the choice is stable across starts. `None` when no page
|
||||||
|
/// invites anyone.
|
||||||
|
pub fn most_privileged_group(questions: &HashMap<String, Question>) -> Option<String> {
|
||||||
|
let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||||
|
for q in questions.values() {
|
||||||
|
let Some(group) = q.qualifies.as_deref() else { continue };
|
||||||
|
for alt in &q.alternatives {
|
||||||
|
if let Some(invite) = &alt.invite {
|
||||||
|
reach.entry(group).or_default().insert(invite.group.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reach
|
||||||
|
.iter()
|
||||||
|
.max_by(|(a, x), (b, y)| x.len().cmp(&y.len()).then_with(|| b.cmp(a)))
|
||||||
|
.map(|(group, _)| group.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bucket key for one seed: stable, and within NATS key rules.
|
||||||
|
pub fn seed_key(email: &str, group: &str) -> String {
|
||||||
|
let clean = |s: &str| s.trim().to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::<String>();
|
||||||
|
format!("{}.{}", clean(group), clean(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everyone the content names as responsible, by address (lowercased,
|
||||||
|
/// `mailto:` taken off): their name as the first page gives it, and the
|
||||||
|
/// questions they answer for, in page order.
|
||||||
|
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
|
||||||
|
let mut ids: Vec<&String> = questions.keys().collect();
|
||||||
|
ids.sort();
|
||||||
|
let mut out: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
|
||||||
|
for id in ids {
|
||||||
|
let q = &questions[id];
|
||||||
|
let Some(r) = &q.responsible else { continue };
|
||||||
|
let email = contact_email(&r.contact);
|
||||||
|
let Some(email) = email else { continue };
|
||||||
|
let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
|
||||||
|
if !entry.1.contains(&q.name) {
|
||||||
|
entry.1.push(q.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A responsible `contact` as an address: `mailto:` off, lowercased;
|
||||||
|
/// `None` for a phone number or anything else that is not one.
|
||||||
|
pub fn contact_email(contact: &str) -> Option<String> {
|
||||||
|
let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||||
|
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first person in, then everyone the content names as responsible.
|
||||||
|
pub async fn run(state: crate::server::AppState) {
|
||||||
|
seed_admin(&state).await;
|
||||||
|
responsible(&state).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
|
||||||
|
match state.jetstream.get_key_value(SEED_BUCKET).await {
|
||||||
|
Ok(store) => Some(store),
|
||||||
|
Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await {
|
||||||
|
Ok(store) => Some(store),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn responsible(state: &crate::server::AppState) {
|
||||||
|
let people = responsible_people(&state.questions.load());
|
||||||
|
if people.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(kanidm) = crate::people::Kanidm::from_env() else {
|
||||||
|
tracing::info!(people = people.len(), "the content names responsible people, but invites are not configured (KANIDM_API_TOKEN) - no accounts made");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(store) = seed_bucket(state).await else { return };
|
||||||
|
let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
|
||||||
|
for (email, (name, questions)) in people {
|
||||||
|
let key = seed_key(&email, group.as_deref().unwrap_or("responsible"));
|
||||||
|
if let Ok(Some(_)) = store.get(&key).await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name };
|
||||||
|
// With a group, one call makes the account if there is none and
|
||||||
|
// adds them either way; without one, only a missing account is
|
||||||
|
// made. Either way a person is mailed only when an account was
|
||||||
|
// made for them: someone who had one already has their way in.
|
||||||
|
let made = match &group {
|
||||||
|
Some(group) => kanidm.invite(&name, &email, group).await,
|
||||||
|
None => match kanidm.has_person_with_mail(&email).await {
|
||||||
|
Ok(true) => {
|
||||||
|
let _ = store.put(&key, "existing".into()).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(false) => kanidm.account(&name, &email).await,
|
||||||
|
Err(e) => Err(e),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
match made {
|
||||||
|
Ok(invited) => {
|
||||||
|
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||||
|
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
||||||
|
let _ = store.put(&key, bytes.into()).await;
|
||||||
|
}
|
||||||
|
if invited.created {
|
||||||
|
crate::mail::on_responsible(state, &invited, &questions).await;
|
||||||
|
}
|
||||||
|
tracing::info!(user = %invited.username, created = invited.created, group = group.as_deref().unwrap_or(""), "a person the content names as responsible has their account");
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(%email, "could not set up a responsible person, leaving them for the next start: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one.
|
||||||
|
async fn seed_admin(state: &crate::server::AppState) {
|
||||||
|
let Some(email) = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@')) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let name = std::env::var("SEED_ADMIN_NAME")
|
||||||
|
.ok()
|
||||||
|
.map(|n| n.trim().to_string())
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string());
|
||||||
|
let group = match std::env::var("SEED_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()) {
|
||||||
|
Some(g) => g,
|
||||||
|
None => match most_privileged_group(&state.questions.load()) {
|
||||||
|
Some(g) => g,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("SEED_ADMIN_EMAIL is set, but no page invites anyone and SEED_GROUP is unset - nobody to seed into");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let key = seed_key(&email, &group);
|
||||||
|
let Some(store) = seed_bucket(state).await else { return };
|
||||||
|
if let Ok(Some(_)) = store.get(&key).await {
|
||||||
|
tracing::info!(group, "the first person was already invited into this group; not again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(kanidm) = crate::people::Kanidm::from_env() else {
|
||||||
|
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but invites are not configured on this instance (KANIDM_URL, KANIDM_API_TOKEN) - nobody was seeded");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kanidm may be starting beside us: a minute apart, for twenty.
|
||||||
|
let mut attempt = 0;
|
||||||
|
let invited = loop {
|
||||||
|
attempt += 1;
|
||||||
|
match kanidm.invite(&name, &email, &group).await {
|
||||||
|
Ok(invited) => break invited,
|
||||||
|
Err(e) if attempt < 20 => {
|
||||||
|
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(group, "seeding the first person failed for good: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||||
|
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await;
|
||||||
|
if let Err(e) = store.put(&key, bytes.into()).await {
|
||||||
|
tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::mail::on_invite(state, &invited).await;
|
||||||
|
tracing::info!(group, user = %invited.username, created = invited.created, "the first person is invited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn page(id: &str, qualifies: Option<&str>, invites: &[&str]) -> (String, Question) {
|
||||||
|
let alternatives: String = invites
|
||||||
|
.iter()
|
||||||
|
.map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n"))
|
||||||
|
.collect();
|
||||||
|
let yaml = format!("name: Q?\n{}alternatives:\n{}", qualifies.map(|q| format!("qualifies: {q}\n")).unwrap_or_default(), if alternatives.is_empty() { " - name: A\n".to_string() } else { alternatives });
|
||||||
|
let mut q: Question = serde_yaml::from_str(&yaml).unwrap();
|
||||||
|
q.id = id.to_string();
|
||||||
|
(id.to_string(), q)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_first_person_goes_where_the_most_invitations_can_be_made() {
|
||||||
|
let qs: HashMap<String, Question> = [
|
||||||
|
page("/", None, &[]),
|
||||||
|
page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]),
|
||||||
|
page("/kasserer", Some("kasserer"), &["kasserer"]),
|
||||||
|
page("/trafikk", Some("trafikk"), &["trafikk"]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret"));
|
||||||
|
// A tie goes to the name that sorts first, every time.
|
||||||
|
let tie: HashMap<String, Question> = [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])].into_iter().collect();
|
||||||
|
assert_eq!(most_privileged_group(&tie).as_deref(), Some("a"));
|
||||||
|
// No invitations anywhere: nobody to seed into without SEED_GROUP.
|
||||||
|
let none: HashMap<String, Question> = [page("/", None, &[])].into_iter().collect();
|
||||||
|
assert!(most_privileged_group(&none).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everyone_named_responsible_is_found_once_by_their_address() {
|
||||||
|
let mut qs: HashMap<String, Question> = HashMap::new();
|
||||||
|
for (id, name, contact) in [("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), ("/b", "Ringe?", "+47 900 00 000"), ("/c", "Trafikk?", "trafikk@vel.no")] {
|
||||||
|
let mut q: Question = serde_yaml::from_str(&format!("name: \"{name}\"\nresponsible: {{ name: Styret, contact: \"{contact}\" }}\nalternatives:\n - name: A\n")).unwrap();
|
||||||
|
q.id = id.to_string();
|
||||||
|
qs.insert(id.to_string(), q);
|
||||||
|
}
|
||||||
|
let people = responsible_people(&qs);
|
||||||
|
assert_eq!(people.keys().collect::<Vec<_>>(), ["styret@vel.no", "trafikk@vel.no"]);
|
||||||
|
assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]);
|
||||||
|
assert_eq!(contact_email("+47 900 00 000"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_seed_is_remembered_by_address_and_group() {
|
||||||
|
assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no");
|
||||||
|
assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,11 +241,13 @@ pub async fn callback(
|
|||||||
// raw compact JWT string "header.payload.signature".
|
// raw compact JWT string "header.payload.signature".
|
||||||
let groups = extract_groups_claim(&id_token);
|
let groups = extract_groups_claim(&id_token);
|
||||||
|
|
||||||
|
let email = claims.email().map(|e| e.as_str().trim().to_lowercase());
|
||||||
let user = User {
|
let user = User {
|
||||||
sub: claims.subject().as_str().to_string(),
|
sub: claims.subject().as_str().to_string(),
|
||||||
username,
|
username,
|
||||||
display_name,
|
display_name,
|
||||||
groups,
|
groups,
|
||||||
|
email,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rotate the session id on privilege change, then store the user.
|
// Rotate the session id on privilege change, then store the user.
|
||||||
|
|||||||
@@ -358,6 +358,34 @@ main.not-found {
|
|||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only for the person a page names as responsible: a quiet card at
|
||||||
|
the foot of their own page for suggesting a change to it. */
|
||||||
|
.suggest-edit {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: 2.4rem auto 0;
|
||||||
|
padding: 1.2rem 1.4rem;
|
||||||
|
border: 0.06rem dashed var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-edit h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-edit p {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-edit textarea {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
.question-nav {
|
.question-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user