Invites and suggestions are cases; the responsible follow the content
Test / test (pull_request) Successful in 56s
Test / test (pull_request) Successful in 56s
Five things the first cut of accounts-from-content left open. The first person and the responsible people are set up side by side, and both try again a minute apart: a seed that waited twenty minutes on Kanidm used to hold everyone else up, and a responsible person who failed waited for the next restart. The responsible are gone over again whenever the content changes. watch_for_reload publishes portal.content.reloaded once new content is in place, and seed.rs listens. Being responsible ends when the content stops saying so: someone no page names any more leaves RESPONSIBLE_GROUP in the same pass, if portal is who put them there (Invited.already_member). The account stays. An invite keeps itself current. portal_invites has an expired state; signing in closes a person's invites, a sweep moves the ones whose link ran out, and a desk's move from expired back to open makes a fresh link and mails it. Invites made at start are on file with the desks'. A suggested edit is a case in the built-in portal_edit_suggestions bucket, the person who made it is mailed at each move, and the card lists what they sent and where it stands. Found on the way, against Kanidm 1.11.2: a link's end comes as seconds since the epoch, which portal did not read, so invite mails said "until" and nothing; and a mail search answered with a 5xx was taken for a refusal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7a50ce77b2
commit
ef4789f9b2
+6
-2
@@ -6,8 +6,12 @@ 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.
|
||||
- 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`), side by side, and each tries again a minute apart, twenty times, while Kanidm comes up. The responsible are gone over again whenever the content changes (`portal.content.reloaded`, published once new content is in place), so a new page's person is set up without a restart - and someone the content has stopped naming leaves `RESPONSIBLE_GROUP` in the same pass, if portal is who put them there.
|
||||
- An invite is a case that keeps itself current (`portal_invites`: `open`, `expired`, `done`). Signing in closes a person's invites; a sweep every ten minutes moves one whose link ran out unused to `expired`, and takes the link off the record. From `expired` a desk may send it again: the move back to `open` makes a fresh link and mails it. Content offers that with `{ from: expired, to: open }` on the list of invites. The invites portal makes itself at start are on file with the rest.
|
||||
- Fixed: an invite mail said the link worked "until" and then nothing. Kanidm 1.11 gives a link's end as seconds since the epoch, and portal read only the older RFC 3339 string.
|
||||
- Fixed: a search by mail that Kanidm failed to answer (a 5xx) was read as "not permitted", and the invite went on to match by username - which could give someone with an account under another name a second one. Only a refusal is read that way now.
|
||||
- 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`, published on `portal.edits.suggested`, and mailed to the site's inbox with them as reply-to. It is a case in the built-in `portal_edit_suggestions` bucket - `open`, `discussed`, `done` or `declined` - so a desk that lists the bucket takes it up and settles it, the person who made it is mailed at each move, and the card shows them what became of what they sent before. The session now carries the signed-in person's email.
|
||||
- The mail to someone named responsible names the first three questions and counts the rest; on a site where one address answers for every page it used to list them all.
|
||||
- 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)
|
||||
|
||||
+62
-8
@@ -104,11 +104,18 @@ At start, portal:
|
||||
questions, with their link. With `RESPONSIBLE_GROUP` they all join
|
||||
it; someone who already had an account is added without a mail.
|
||||
|
||||
Kanidm may still be starting beside portal, so the first person's
|
||||
invite is tried again a minute apart, up to twenty times. A responsible
|
||||
person who could not be set up is left for the next start. The log says
|
||||
The two run side by side. Kanidm may still be starting beside portal,
|
||||
so each is tried again a minute apart, up to twenty times. The log says
|
||||
what happened, per person.
|
||||
|
||||
The second happens again **whenever the content changes**, so a new
|
||||
page naming a new person sets them up without a restart. And it works
|
||||
both ways: someone the content has stopped naming leaves
|
||||
`RESPONSIBLE_GROUP` in the same pass, and with it whatever else that
|
||||
group opens. Only if portal is who put them there - a person who was in
|
||||
the group before is left in it - and their account stays, since it is
|
||||
theirs.
|
||||
|
||||
## 5. The rest come in from the desks
|
||||
|
||||
From here nobody touches Kanidm:
|
||||
@@ -119,16 +126,60 @@ From here nobody touches Kanidm:
|
||||
in `aggregates.yaml` puts the person a case is about into that group
|
||||
when the case arrives there - an approved committee application that
|
||||
*is* the membership.
|
||||
- **A new page with a new responsible person** gets them an account at
|
||||
the next start.
|
||||
- **A new page with a new responsible person** gets them an account
|
||||
when the page goes live.
|
||||
|
||||
### An invite that nobody used
|
||||
|
||||
A link lives a day. Every invite - a desk's, and the ones portal made
|
||||
at start - is a case in `portal_invites`, and keeps itself current:
|
||||
|
||||
| state | means | how it gets there |
|
||||
|---|---|---|
|
||||
| `open` | the link is waiting | the invite was sent, or sent again |
|
||||
| `done` | the person is in | they signed in to the site; or a desk says so |
|
||||
| `expired` | the link ran out unused | a sweep, every ten minutes |
|
||||
|
||||
A desk that lists the bucket offers the way back with a transition from
|
||||
`expired` to `open`. Pressing it makes a fresh link and mails it:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: portal_invites }
|
||||
requires_group: styret
|
||||
transitions:
|
||||
- { from: open, to: done, label: Is in }
|
||||
- { from: expired, to: open, label: Send again }
|
||||
- { from: expired, to: done, label: Is in }
|
||||
```
|
||||
|
||||
## 6. What the people who ask the questions can do
|
||||
|
||||
Signed in, a person a page names as responsible sees a card at the
|
||||
foot of their own pages to suggest a change. The server checks it is
|
||||
really them; the suggestion is kept (`portal_edit_suggestions`),
|
||||
announced (`portal.edits.suggested`), and mailed to the site's inbox
|
||||
with them as reply-to, so answering it answers them.
|
||||
really them, and the suggestion is mailed to the site's inbox with them
|
||||
as reply-to, so answering it answers them.
|
||||
|
||||
It is also a case, in the built-in `portal_edit_suggestions` bucket, so
|
||||
it does not depend on somebody reading that inbox. Give a desk the
|
||||
list, and whoever sits there takes it up and settles it:
|
||||
|
||||
```yaml
|
||||
resource:
|
||||
source: { kind: kv, bucket: portal_edit_suggestions }
|
||||
requires_group: styret
|
||||
transitions:
|
||||
- { from: open, to: discussed, label: Looking at it }
|
||||
- { from: open, to: done, label: Changed }
|
||||
- { from: open, to: declined, label: Will not change }
|
||||
- { from: discussed, to: done, label: Changed }
|
||||
- { from: discussed, to: declined, label: Will not change }
|
||||
```
|
||||
|
||||
The person who suggested it is mailed at each move, in the site's
|
||||
language, and the card on their page lists what they have sent and
|
||||
where each one stands. A content repo that declares the bucket itself
|
||||
in `aggregates.yaml`, with a `mail:` on a state, sends that instead.
|
||||
|
||||
Their group can also be the way into other tools. The vel's
|
||||
`redaktor` group signs in to the project Gitea with the same account:
|
||||
@@ -143,4 +194,7 @@ a second OAuth2 client scoped to that group alone.
|
||||
| `Item not found` setting the login page's name or logo | those are system settings only `admin` may change, not `idm_admin` - or the CLI and server versions differ |
|
||||
| accounts are made but nobody is mailed | `site.yaml` has no `mail.from`, or gdo is not running on the host |
|
||||
| someone responsible never got a mail | they already had an account (they are only added to `RESPONSIBLE_GROUP`), or their `contact` is not an email address |
|
||||
| an invite stays `open` though the person has set a passkey | Kanidm does not show a passkey to the onboarding account; it becomes `done` when they sign in to the site |
|
||||
| "send again" is not on the desk | the list has no `{ from: expired, to: open }` transition, or the content declares `portal_invites` itself without an `expired` state |
|
||||
| someone left a page and is still in `RESPONSIBLE_GROUP` | they were in the group before portal added them, so it is not portal's to undo |
|
||||
| the first person was invited twice | it cannot be, per address and group; changing `SEED_ADMIN_EMAIL` or `SEED_GROUP` invites the new pair |
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ values.
|
||||
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
|
||||
| `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 |
|
||||
| `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins, at start and whenever the content changes, and leaves when no page names them any more; they get an account either way |
|
||||
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
|
||||
| `GITEA_API_TOKEN` | authenticated resource pulls |
|
||||
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
|
||||
|
||||
+5
-1
@@ -167,7 +167,7 @@ pub async fn transition_answers(
|
||||
/// transition retries. Keeps "zap the whole event stream" a safe
|
||||
/// operation instead of one that strands every pre-wipe record.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn transition_or_reseed(
|
||||
pub(crate) async fn transition_or_reseed(
|
||||
js: &async_nats::jetstream::Context,
|
||||
schema: &crate::aggregates::AggregateSchema,
|
||||
item_id: &str,
|
||||
@@ -291,6 +291,10 @@ async fn apply_transition(
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
||||
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
|
||||
// An expired invite sent again: the move back is the asking.
|
||||
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
|
||||
crate::people::on_reopened(state, &item.item_id).await;
|
||||
}
|
||||
|
||||
// The decision extends the DAG as a child of the answer's own
|
||||
// submission hash, published like any other answer.
|
||||
|
||||
+136
-14
@@ -633,13 +633,41 @@ pub fn is_responsible(user: Option<&User>, contact: &str) -> bool {
|
||||
user.and_then(|u| u.email.as_deref()).is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
|
||||
}
|
||||
|
||||
/// One suggestion as the person who made it sees it: what they wrote
|
||||
/// and where it stands.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MySuggestion {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub state: String,
|
||||
pub submitted_ms: i64,
|
||||
}
|
||||
|
||||
/// The translation key for where a suggestion stands. A state the
|
||||
/// content added to the bucket's graph has no words here, and shows as
|
||||
/// itself.
|
||||
fn suggestion_state_key(state: &str) -> Option<&'static str> {
|
||||
match state {
|
||||
"open" => Some("suggestion_open"),
|
||||
"discussed" => Some("suggestion_discussed"),
|
||||
"done" => Some("suggestion_done"),
|
||||
"declined" => Some("suggestion_declined"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A change suggested by the person responsible for the page: one
|
||||
/// field, sent to the site's inbox. Shown only to them.
|
||||
/// field, which becomes a case for whoever keeps the site, and below
|
||||
/// it what became of the ones sent before. 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 mine = {
|
||||
let question_id = question_id.clone();
|
||||
Resource::new(move || send.version().get(), move |_| my_suggestions(question_id.clone()))
|
||||
};
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
let value = text.get().map(|t| t.value()).unwrap_or_default();
|
||||
@@ -662,6 +690,30 @@ fn SuggestEdit(question_id: String) -> impl IntoView {
|
||||
>
|
||||
<p>{move || t(&lang().get(), "suggest_edit_sent")}</p>
|
||||
</Show>
|
||||
<Transition fallback=|| ()>
|
||||
{move || {
|
||||
mine.get().and_then(|res| res.ok()).filter(|items| !items.is_empty()).map(|items| view! {
|
||||
<h3>{move || t(&lang().get(), "suggestions_yours")}</h3>
|
||||
<ul class="suggest-edit-mine">
|
||||
<For
|
||||
each=move || items.clone()
|
||||
key=|s| (s.id.clone(), s.state.clone())
|
||||
children=|s: MySuggestion| {
|
||||
let state = s.state.clone();
|
||||
let label = move || suggestion_state_key(&state).map(|key| t(&lang().get(), key).to_string()).unwrap_or_else(|| state.clone());
|
||||
view! {
|
||||
<li>
|
||||
<span class="suggest-edit-state" data-state=s.state.clone()>{label}</span>
|
||||
<span class="suggest-edit-text">{s.text}</span>
|
||||
</li>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
})
|
||||
}}
|
||||
</Transition>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -2483,11 +2535,15 @@ pub struct SubmitResult {
|
||||
|
||||
/// 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.
|
||||
/// be the page's responsible contact. It becomes a case in
|
||||
/// `portal_edit_suggestions` (open, discussed, done or declined, see
|
||||
/// the built-in aggregates), is announced like any answer and on
|
||||
/// `portal.edits.suggested`, and is 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::chain::hash_node;
|
||||
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
@@ -2505,25 +2561,91 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
|
||||
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,
|
||||
// The fields a desk shows and a mail may name: who, what, and
|
||||
// which page. `email` is where word of what became of it goes.
|
||||
let responses = serde_json::json!({
|
||||
"name": user.display_name, "email": email, "by": user.username,
|
||||
"page": question_id, "question": question.name, "text": text,
|
||||
});
|
||||
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() })
|
||||
let id = hash_node(&question_id, &[], &responses, now_ms);
|
||||
crate::answers::store_answer(
|
||||
&state.jetstream,
|
||||
&state.aggregates.load(),
|
||||
crate::content::EDIT_SUGGESTIONS_BUCKET,
|
||||
id.clone(),
|
||||
&question_id,
|
||||
SUGGESTION_ALTERNATIVE,
|
||||
&responses,
|
||||
now_ms,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?,
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let event = AnswerSubmitted {
|
||||
chain_hash: id,
|
||||
parent_hashes: Vec::new(),
|
||||
question_id: question_id.clone(),
|
||||
alternative: SUGGESTION_ALTERNATIVE.to_string(),
|
||||
responses: responses.clone(),
|
||||
timestamp_ms: now_ms,
|
||||
};
|
||||
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()))?;
|
||||
if let Err(e) = emit_answer_submitted(&state.nats, &event).await {
|
||||
tracing::warn!(question_id, "the suggestion is on file but was not announced: {e}");
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec(&responses) {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// What a suggestion is recorded under, where an answer has the name
|
||||
/// of the alternative that was chosen.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub const SUGGESTION_ALTERNATIVE: &str = "Suggested change";
|
||||
|
||||
/// The signed-in person's own suggestions for a page, newest first,
|
||||
/// with where each one stands. Theirs only: matched on the session's
|
||||
/// address, which is what a suggestion was filed under.
|
||||
#[server(endpoint = "my_suggestions")]
|
||||
pub async fn my_suggestions(question_id: String) -> Result<Vec<MySuggestion>, 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 Some(email) = user.and_then(|u| u.email).map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
// Made on first write: nobody has suggested anything yet.
|
||||
let Ok(store) = state.jetstream.get_key_value(crate::content::EDIT_SUGGESTIONS_BUCKET).await else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut mine = Vec::new();
|
||||
for key in keys {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
let theirs = answer.responses.get("email").and_then(|v| v.as_str()).is_some_and(|e| e.trim().to_lowercase() == email);
|
||||
if theirs && answer.question_id == question_id {
|
||||
mine.push(MySuggestion {
|
||||
id: answer.id,
|
||||
text: answer.responses.get("text").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
state: answer.state,
|
||||
submitted_ms: answer.submitted_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
mine.sort_by_key(|s| std::cmp::Reverse(s.submitted_ms));
|
||||
Ok(mine)
|
||||
}
|
||||
|
||||
#[server(endpoint = "submit_answer")]
|
||||
pub async fn submit_answer(
|
||||
question_id: String,
|
||||
|
||||
@@ -171,6 +171,13 @@ pub async fn load_questions_from_gitea(
|
||||
#[cfg(feature = "ssr")]
|
||||
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload";
|
||||
|
||||
/// Published by `watch_for_reload` once new content is in place, for
|
||||
/// whatever acts on what the content says rather than serving it: the
|
||||
/// accounts it mandates (`seed.rs`). No payload; the content is read
|
||||
/// from where it always is.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub const CONTENT_RELOADED_SUBJECT: &str = "portal.content.reloaded";
|
||||
|
||||
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
|
||||
/// atomically swaps both `questions` and `aggregates` on every
|
||||
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together,
|
||||
@@ -233,6 +240,7 @@ pub async fn watch_for_reload(
|
||||
aggregates.store(std::sync::Arc::new(loaded_aggregates));
|
||||
site.store(std::sync::Arc::new(loaded_site));
|
||||
tracing::info!(count, "reloaded content");
|
||||
let _ = nats.publish(CONTENT_RELOADED_SUBJECT, "".into()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "content reload failed, keeping last-good content");
|
||||
|
||||
+20
-1
@@ -36,6 +36,12 @@ pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
|
||||
/// runtime's sweeper (`announce.rs`) is what moves the records.
|
||||
pub const EVENTS_BUCKET: &str = "portal_events";
|
||||
|
||||
/// Bucket for changes suggested by the people the content names as
|
||||
/// responsible (`app::suggest_page_edit`): a case like any other, so a
|
||||
/// suggestion is taken up, carried out or turned down by someone at a
|
||||
/// desk, and the person who made it hears which.
|
||||
pub const EDIT_SUGGESTIONS_BUCKET: &str = "portal_edit_suggestions";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
const BUILTIN_AGGREGATES_YAML: &str = "\
|
||||
aggregates:
|
||||
@@ -53,9 +59,22 @@ aggregates:
|
||||
initial: open
|
||||
states:
|
||||
open: { event: invited }
|
||||
expired: { event: expired }
|
||||
done: { event: done }
|
||||
transitions:
|
||||
open: [done]
|
||||
open: [done, expired]
|
||||
expired: [open, done]
|
||||
- bucket: portal_edit_suggestions
|
||||
attended_by: whoever keeps the content; every suggestion is mailed to the site's inbox too
|
||||
initial: open
|
||||
states:
|
||||
open: { event: suggested }
|
||||
discussed: { event: discussed }
|
||||
done: { event: done }
|
||||
declined: { event: declined }
|
||||
transitions:
|
||||
open: [discussed, done, declined]
|
||||
discussed: [done, declined]
|
||||
";
|
||||
|
||||
/// Adds the runtime's own state graphs to a content repo's. Content
|
||||
|
||||
+15
@@ -38,6 +38,11 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("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", "suggestions_yours") => "What you have suggested here",
|
||||
("en", "suggestion_open") => "Sent",
|
||||
("en", "suggestion_discussed") => "Being looked at",
|
||||
("en", "suggestion_done") => "Done",
|
||||
("en", "suggestion_declined") => "Not done",
|
||||
("en", "the_start") => "the start",
|
||||
|
||||
("no", "also_worth_asking") => "Også verdt å spørre",
|
||||
@@ -62,6 +67,11 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("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", "suggestions_yours") => "Det du har foreslått her",
|
||||
("no", "suggestion_open") => "Sendt",
|
||||
("no", "suggestion_discussed") => "Blir sett på",
|
||||
("no", "suggestion_done") => "Gjort",
|
||||
("no", "suggestion_declined") => "Ikke gjort",
|
||||
("no", "the_start") => "starten",
|
||||
|
||||
_ => return None,
|
||||
@@ -101,6 +111,11 @@ mod tests {
|
||||
"suggest_edit_help",
|
||||
"suggest_edit_send",
|
||||
"suggest_edit_sent",
|
||||
"suggestions_yours",
|
||||
"suggestion_open",
|
||||
"suggestion_discussed",
|
||||
"suggestion_done",
|
||||
"suggestion_declined",
|
||||
] {
|
||||
assert!(lookup("en", key).is_some(), "en missing {key}");
|
||||
assert!(lookup("no", key).is_some(), "no missing {key}");
|
||||
|
||||
+134
-6
@@ -265,6 +265,11 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
|
||||
/// failed request.
|
||||
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) {
|
||||
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
|
||||
// A suggestion has its own words for this unless the content
|
||||
// gave the state a mail, which then is the one sent.
|
||||
if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET {
|
||||
on_suggestion_state(state, id, to_state, responses).await;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let site = state.site.load();
|
||||
@@ -301,6 +306,33 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
||||
}
|
||||
}
|
||||
|
||||
/// The questions a person answers for, as a mail names them: each in
|
||||
/// quotes, and past the first three only how many more - someone who
|
||||
/// answers for a whole site is not read its table of contents.
|
||||
pub fn questions_listed(questions: &[String], norwegian: bool) -> String {
|
||||
let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") };
|
||||
let and = if norwegian { "og" } else { "and" };
|
||||
match questions {
|
||||
[] => String::new(),
|
||||
[one] => quote(one),
|
||||
few if few.len() <= 3 => {
|
||||
let quoted: Vec<String> = few.iter().map(quote).collect();
|
||||
format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1])
|
||||
}
|
||||
many => {
|
||||
let quoted: Vec<String> = many[..3].iter().map(quote).collect();
|
||||
let more = many.len() - 3;
|
||||
let others = match (norwegian, more) {
|
||||
(true, 1) => "ett spørsmål til".to_string(),
|
||||
(true, n) => format!("{n} spørsmål til"),
|
||||
(false, 1) => "one more question".to_string(),
|
||||
(false, n) => format!("{n} more questions"),
|
||||
};
|
||||
format!("{} {and} {others}", quoted.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -312,12 +344,7 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
|
||||
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 list = questions_listed(questions, norwegian);
|
||||
let (subject, body) = if norwegian {
|
||||
(
|
||||
"Du står som ansvarlig for et spørsmål hos {site_name}".to_string(),
|
||||
@@ -360,6 +387,81 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
|
||||
}
|
||||
}
|
||||
|
||||
/// What the person who suggested a change hears when it moves, as
|
||||
/// subject and body; `None` for a state that says nothing new to them
|
||||
/// (it arriving, or one the content added). `{name}`, `{question}`,
|
||||
/// `{page}`, `{quoted}`.
|
||||
pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str, &'static str)> {
|
||||
Some(match (to_state, norwegian) {
|
||||
("discussed", true) => (
|
||||
"Forslaget ditt om «{question}» blir sett på",
|
||||
"Hei {name},\n\nForslaget ditt til endring på {page} er tatt opp, og blir sett på nå. Du får beskjed når det er avgjort.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("done", true) => (
|
||||
"Endringen du foreslo i «{question}» er gjort",
|
||||
"Hei {name},\n\nEndringen du foreslo på {page} er gjort. Takk for at du sa fra.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("declined", true) => (
|
||||
"Forslaget ditt om «{question}» blir ikke gjort",
|
||||
"Hei {name},\n\nForslaget ditt til endring på {page} blir ikke gjort slik det står. Svar på denne e-posten om du vil vite hvorfor, eller vil foreslå det på en annen måte.\n\nDu skrev:\n\n{quoted}",
|
||||
),
|
||||
("discussed", false) => (
|
||||
"Your suggestion about \u{201c}{question}\u{201d} is being looked at",
|
||||
"Hello {name},\n\nYour suggested change to {page} has been taken up and is being looked at. You will hear when it is settled.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
("done", false) => (
|
||||
"The change you suggested to \u{201c}{question}\u{201d} is made",
|
||||
"Hello {name},\n\nThe change you suggested to {page} is made. Thank you for saying so.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
("declined", false) => (
|
||||
"Your suggestion about \u{201c}{question}\u{201d} will not be made",
|
||||
"Hello {name},\n\nYour suggested change to {page} will not be made as it stands. Answer this mail if you want to know why, or to suggest it another way.\n\nYou wrote:\n\n{quoted}",
|
||||
),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A suggestion moved: tell whoever made it. Replies go to the site's
|
||||
/// inbox, which is who decided.
|
||||
async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) {
|
||||
let site = state.site.load();
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let Some((subject, body)) = suggestion_words(to_state, norwegian) else {
|
||||
return;
|
||||
};
|
||||
let Some(cfg) = &site.mail else {
|
||||
tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told");
|
||||
return;
|
||||
};
|
||||
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
|
||||
let to = field("email");
|
||||
if !to.contains('@') {
|
||||
tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent");
|
||||
return;
|
||||
}
|
||||
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("name".into(), field("name").into());
|
||||
vars.insert("question".into(), field("question").into());
|
||||
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
|
||||
vars.insert("quoted".into(), quoted.into());
|
||||
let text = render(body, &vars);
|
||||
let mail = Outgoing {
|
||||
v: CONTRACT,
|
||||
id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET),
|
||||
site: site_name(&site),
|
||||
from: cfg.from.clone(),
|
||||
reply_to: cfg.reply_to.clone(),
|
||||
to,
|
||||
subject: render(subject, &vars),
|
||||
html: to_html(&text),
|
||||
text,
|
||||
};
|
||||
if let Err(e) = publish(&state.jetstream, &mail).await {
|
||||
tracing::error!(id, to_state, "publishing the suggestion 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.
|
||||
@@ -460,6 +562,32 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mail_names_three_questions_and_counts_the_rest() {
|
||||
let q = |n: usize| (1..=n).map(|i| format!("Q{i}?")).collect::<Vec<_>>();
|
||||
assert_eq!(questions_listed(&q(0), true), "");
|
||||
assert_eq!(questions_listed(&q(1), true), "«Q1?»");
|
||||
assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»");
|
||||
assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til");
|
||||
assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til");
|
||||
assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}");
|
||||
assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suggestion_says_something_each_time_it_moves_and_nothing_on_arrival() {
|
||||
for norwegian in [true, false] {
|
||||
for state in ["discussed", "done", "declined"] {
|
||||
let (subject, body) = suggestion_words(state, norwegian).expect(state);
|
||||
assert!(subject.contains("{question}"), "{state}: the subject names the question");
|
||||
assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what");
|
||||
}
|
||||
// Arriving is told to the inbox, not back to who sent it.
|
||||
assert!(suggestion_words("open", norwegian).is_none());
|
||||
assert!(suggestion_words("a_state_the_content_added", norwegian).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_take_fields_and_leave_stray_braces_alone() {
|
||||
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
|
||||
|
||||
+5
-1
@@ -108,8 +108,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
// windows (see announce.rs).
|
||||
tokio::spawn(portal::announce::run(state.clone()));
|
||||
tokio::spawn(portal::deadlines::run(state.clone()));
|
||||
// The first person in, when SEED_ADMIN_EMAIL names one (seed.rs).
|
||||
// The accounts the content mandates: the first person in, and
|
||||
// everyone a page names as responsible, at start and whenever the
|
||||
// content changes (seed.rs).
|
||||
tokio::spawn(portal::seed::run(state.clone()));
|
||||
// Invites whose link ran out, or whose person is in (people.rs).
|
||||
tokio::spawn(portal::people::run(state.clone()));
|
||||
|
||||
// Replies to case mail, matched to their record by whatever reads
|
||||
// the mailbox (uhhm/gdo over JMAP) and turned into notes here. A
|
||||
|
||||
+403
-20
@@ -8,14 +8,35 @@
|
||||
//! Talks to Kanidm's REST API with a service-account token
|
||||
//! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The
|
||||
//! service account belongs to `idm_people_on_boarding`, which may
|
||||
//! create persons and issue first-credential resets and nothing else,
|
||||
//! so a leaked token cannot touch existing credentials or admins.
|
||||
//! create persons and issue credential resets for ordinary people and
|
||||
//! nothing else, so a leaked token cannot touch an admin.
|
||||
//!
|
||||
//! **An invite is a case.** Each one is a record in `portal_invites`:
|
||||
//! `open` while the link waits, `done` once the person is in, `expired`
|
||||
//! when the link ran out unused. Nobody has to keep that current by
|
||||
//! hand: signing in to the site closes a person's invites, and a sweep
|
||||
//! moves the ones whose link has run out (`sweep_invites`). From
|
||||
//! `expired` a desk may send it again - the move back to `open` makes a
|
||||
//! fresh link and mails it (`on_reopened`).
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const INVITES_BUCKET: &str = "portal_invites";
|
||||
pub const INVITED_SUBJECT: &str = "portal.people.invited";
|
||||
/// The states an invite moves between, besides the schema's initial
|
||||
/// `open` (see `BUILTIN_AGGREGATES_YAML`).
|
||||
pub const INVITE_EXPIRED: &str = "expired";
|
||||
pub const INVITE_DONE: &str = "done";
|
||||
/// What an invite made at start is recorded under, where a desk invite
|
||||
/// has the name of the alternative that made it.
|
||||
pub const SEED_ALTERNATIVE: &str = "Invited at start";
|
||||
/// Stamped on a move nobody pressed, in place of a username.
|
||||
pub const BY: &str = "portal";
|
||||
|
||||
/// How long a link lives: the longest Kanidm allows, since it travels
|
||||
/// by email and is read when the person gets to it.
|
||||
const LINK_SECONDS: i64 = 86_400;
|
||||
|
||||
/// What an invite produced: the account, and the link that sets it up.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -29,6 +50,42 @@ pub struct Invited {
|
||||
pub reset_link: Option<String>,
|
||||
pub link_expires_ms: Option<i64>,
|
||||
pub created: bool,
|
||||
/// They were in the group before this invite put them there, so
|
||||
/// the membership is not portal's to take back.
|
||||
#[serde(default)]
|
||||
pub already_member: bool,
|
||||
}
|
||||
|
||||
/// When a link runs out, from Kanidm's `expiry_time`: seconds since
|
||||
/// the epoch in 1.11, an RFC 3339 string before that.
|
||||
fn expiry_ms(value: &serde_json::Value) -> Option<i64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000),
|
||||
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s).ok().map(|t| t.timestamp_millis()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's members as bare account names (`kari`, not
|
||||
/// `kari@id.example.no`). Kanidm answers an empty group with `null`.
|
||||
fn members_from(body: &str) -> Vec<String> {
|
||||
serde_json::from_str::<Option<Vec<String>>>(body)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| m.split('@').next().unwrap_or_default().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a `_credential/_status` answer shows a credential. Only a
|
||||
/// yes means anything: a person with nothing but a passkey is answered
|
||||
/// with an empty list too.
|
||||
fn has_credentials_from(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("creds").and_then(|c| c.as_array()).map(|c| !c.is_empty()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a `GET /v1/person/<name>` answer means the person exists.
|
||||
@@ -96,10 +153,16 @@ impl Kanidm {
|
||||
Some(serde_json::json!({ "filter": { "eq": ["mail", email.trim().to_lowercase()] } })),
|
||||
)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
// Refused is an answer, and the username decides. Anything else
|
||||
// that is not a yes is Kanidm not answering: guessing then could
|
||||
// give someone a second account.
|
||||
if (400..500).contains(&status) {
|
||||
tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead");
|
||||
return Ok(None);
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: searching people by mail: http {status}");
|
||||
}
|
||||
Ok(name_from_search(&text))
|
||||
}
|
||||
|
||||
@@ -124,6 +187,56 @@ impl Kanidm {
|
||||
Ok(self.find_by_mail(email).await?.is_some())
|
||||
}
|
||||
|
||||
/// Who is in a group, by account name.
|
||||
pub async fn members(&self, group: &str) -> anyhow::Result<Vec<String>> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{group}/_attr/member"), None).await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}");
|
||||
}
|
||||
Ok(members_from(&text))
|
||||
}
|
||||
|
||||
/// Takes a person out of a group. Theirs to do for a group the
|
||||
/// service account manages, like adding them was.
|
||||
pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> {
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether Kanidm shows a credential for this person. `false` is
|
||||
/// "not that it can see", never "none": see `has_credentials_from`.
|
||||
pub async fn has_credentials(&self, username: &str) -> anyhow::Result<bool> {
|
||||
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_status"), None).await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: credential status of {username}: http {status}");
|
||||
}
|
||||
Ok(has_credentials_from(&text))
|
||||
}
|
||||
|
||||
/// A one-time link where the person sets their own password or
|
||||
/// passkey, and when it runs out.
|
||||
pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option<i64>)> {
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), None)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Intent {
|
||||
token: String,
|
||||
#[serde(default)]
|
||||
expiry_time: Option<serde_json::Value>,
|
||||
}
|
||||
let intent: Intent = serde_json::from_str(&text)?;
|
||||
Ok((format!("{}/ui/reset?token={}", self.base, intent.token), intent.expiry_time.as_ref().and_then(expiry_ms)))
|
||||
}
|
||||
|
||||
async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result<Invited> {
|
||||
let existing = self.find_by_mail(email).await?;
|
||||
let username = existing.clone().unwrap_or_else(|| username_for(email));
|
||||
@@ -146,7 +259,13 @@ impl Kanidm {
|
||||
}
|
||||
true
|
||||
};
|
||||
let mut already_member = false;
|
||||
if let Some(group) = group {
|
||||
// Someone new is in no group. For anyone else, whether they
|
||||
// were here before is what says whose the membership is; a
|
||||
// failed read says "not before", which errs toward portal
|
||||
// tidying up after itself.
|
||||
already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username));
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
|
||||
.await?;
|
||||
@@ -155,23 +274,8 @@ impl Kanidm {
|
||||
}
|
||||
}
|
||||
let (reset_link, link_expires_ms) = if created {
|
||||
// 24 hours: the longest Kanidm allows, since the link travels
|
||||
// by email and is read when the person gets to it.
|
||||
let (status, text) = self
|
||||
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/86400"), None)
|
||||
.await?;
|
||||
if !(200..300).contains(&status) {
|
||||
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Intent {
|
||||
token: String,
|
||||
#[serde(default)]
|
||||
expiry_time: Option<serde_json::Value>,
|
||||
}
|
||||
let intent: Intent = serde_json::from_str(&text)?;
|
||||
let expires = intent.expiry_time.as_ref().and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).map(|t| t.timestamp_millis());
|
||||
(Some(format!("{}/ui/reset?token={}", self.base, intent.token)), expires)
|
||||
let (link, expires) = self.reset_link(&username).await?;
|
||||
(Some(link), expires)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
@@ -183,10 +287,216 @@ impl Kanidm {
|
||||
reset_link,
|
||||
link_expires_ms,
|
||||
created,
|
||||
already_member,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// When an open invite's link runs out: what Kanidm said, or a link's
|
||||
/// lifetime after it was last sent for a record that does not say.
|
||||
fn link_runs_out(answer: &crate::answers::Answer) -> i64 {
|
||||
answer
|
||||
.responses
|
||||
.get("invited")
|
||||
.and_then(|i| i.get("link_expires_ms"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or_else(|| answer.decided_ms.unwrap_or(answer.submitted_ms) + LINK_SECONDS * 1000)
|
||||
}
|
||||
|
||||
/// The account an invite record is about.
|
||||
fn invited_of(answer: &crate::answers::Answer) -> Option<Invited> {
|
||||
answer.responses.get("invited").and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// Where a waiting invite goes now, if anywhere. Pure: what is known
|
||||
/// about the person comes in as `is_in`.
|
||||
///
|
||||
/// An invite that made no account has no link to wait for: the person
|
||||
/// had their way in already, and was only given a place.
|
||||
pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) -> Option<&'static str> {
|
||||
if answer.state != crate::answers::OPEN_STATE {
|
||||
return None;
|
||||
}
|
||||
let made_an_account = invited_of(answer).is_some_and(|i| i.created);
|
||||
if is_in || !made_an_account {
|
||||
Some(INVITE_DONE)
|
||||
} else if now_ms >= link_runs_out(answer) {
|
||||
Some(INVITE_EXPIRED)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An invite nobody at a desk made - the first person, someone the
|
||||
/// content names as responsible - on file like any other, so the desk
|
||||
/// that reads `portal_invites` sees it, and can send it again.
|
||||
pub async fn record(state: &crate::server::AppState, invited: &Invited, responsible_for: &[String]) {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let mut responses = serde_json::json!({
|
||||
"name": invited.display_name,
|
||||
"email": invited.email,
|
||||
"invited": invited,
|
||||
});
|
||||
if !responsible_for.is_empty() {
|
||||
responses["responsible_for"] = serde_json::json!(responsible_for);
|
||||
}
|
||||
let id = crate::chain::hash_node("/", &[], &responses, now_ms);
|
||||
if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms).await {
|
||||
tracing::warn!(user = %invited.username, "the invite is sent, but could not be put on file for a desk: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves an invite record, the way a desk button would: through the
|
||||
/// event log, onto the projection, and out on the answers subject.
|
||||
async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: crate::answers::Answer, target: &str) {
|
||||
let aggregates = state.aggregates.load();
|
||||
let Some(schema) = aggregates.get(INVITES_BUCKET).filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) else {
|
||||
// A content repo may declare the bucket itself, without this
|
||||
// state. Then the invite stays where its own graph has it.
|
||||
return;
|
||||
};
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": BY });
|
||||
if let Err(e) = crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms).await {
|
||||
tracing::warn!(id = %answer.id, target, "an invite could not be moved: {e}");
|
||||
return;
|
||||
}
|
||||
answer.state = target.to_string();
|
||||
answer.decided_ms = Some(now_ms);
|
||||
answer.decided_by = Some(BY.to_string());
|
||||
// The link is a way in for as long as it is on file; one that is
|
||||
// used or run out is only something to leak.
|
||||
if let Some(invited) = answer.responses.get_mut("invited").and_then(|i| i.as_object_mut()) {
|
||||
invited.insert("reset_link".into(), serde_json::Value::Null);
|
||||
}
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(&answer.id, data.into()).await;
|
||||
}
|
||||
let parent_hashes = vec![answer.id.clone()];
|
||||
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
|
||||
let event = crate::events::AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id: answer.question_id.clone(),
|
||||
alternative: target.to_string(),
|
||||
responses: payload,
|
||||
timestamp_ms: now_ms,
|
||||
};
|
||||
if let Err(e) = crate::events::emit_answer_submitted(&state.nats, &event).await {
|
||||
tracing::warn!(id = %answer.id, target, "the invite was moved but not published: {e}");
|
||||
}
|
||||
tracing::info!(id = %answer.id, target, "an invite moved on its own");
|
||||
}
|
||||
|
||||
/// Every invite on file that is still waiting in one of `states`.
|
||||
async fn invites_in(state: &crate::server::AppState, states: &[&str]) -> Option<(async_nats::jetstream::kv::Store, Vec<crate::answers::Answer>)> {
|
||||
// Made on first write: a site that has invited nobody has no bucket.
|
||||
let store = state.jetstream.get_key_value(INVITES_BUCKET).await.ok()?;
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
store.keys().await.ok()?.try_collect().await.unwrap_or_default()
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for id in keys {
|
||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||
if states.contains(&answer.state.as_str()) {
|
||||
found.push(answer);
|
||||
}
|
||||
}
|
||||
Some((store, found))
|
||||
}
|
||||
|
||||
/// Runs for the life of the process, every ten minutes: a link lives a
|
||||
/// day, so an invite is never shown as waiting long after it stopped.
|
||||
pub async fn run(state: crate::server::AppState) {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(10 * 60));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
sweep_invites(&state).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass over the waiting invites: done for whoever Kanidm shows a
|
||||
/// credential for, expired for a link that ran out.
|
||||
pub async fn sweep_invites(state: &crate::server::AppState) {
|
||||
let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { return };
|
||||
let kanidm = Kanidm::from_env();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
for answer in waiting {
|
||||
let is_in = match (&kanidm, invited_of(&answer)) {
|
||||
(Some(kanidm), Some(invited)) if invited.created => kanidm.has_credentials(&invited.username).await.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(target) = invite_due(&answer, is_in, now_ms) {
|
||||
move_invite(state, &store, answer, target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an invite was this person's: the account it made, or the
|
||||
/// address it went to for someone whose account goes by another name.
|
||||
pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool {
|
||||
let address = invited.email.trim().to_lowercase();
|
||||
invited.username == user.username || (!address.is_empty() && user.email.as_deref().is_some_and(|e| e.trim().to_lowercase() == address))
|
||||
}
|
||||
|
||||
/// Someone signed in: whatever invites were waiting for them, or had
|
||||
/// run out, are done. This is the one sure sign a link was used -
|
||||
/// they are here.
|
||||
pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::User) {
|
||||
let Some((store, waiting)) = invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await else { return };
|
||||
for answer in waiting {
|
||||
if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) {
|
||||
move_invite(&state, &store, answer, INVITE_DONE).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A desk sent an expired invite again (`expired` to `open`): a fresh
|
||||
/// link, on the record and in the mail. The same mail as the first
|
||||
/// time, which for someone the content names as responsible is the one
|
||||
/// that says so.
|
||||
pub async fn on_reopened(state: &crate::server::AppState, id: &str) {
|
||||
let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else { return };
|
||||
let Ok(Some(bytes)) = store.get(id).await else { return };
|
||||
let Ok(mut answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { return };
|
||||
let Some(mut invited) = invited_of(&answer) else {
|
||||
tracing::warn!(id, "an invite was sent again, but the record names no account - nothing sent");
|
||||
return;
|
||||
};
|
||||
let Some(kanidm) = Kanidm::from_env() else {
|
||||
tracing::warn!(id, "an invite was sent again, but invites are not configured on this instance (KANIDM_API_TOKEN) - nothing sent");
|
||||
return;
|
||||
};
|
||||
let (link, expires) = match kanidm.reset_link(&invited.username).await {
|
||||
Ok(made) => made,
|
||||
Err(e) => {
|
||||
tracing::error!(id, user = %invited.username, "a new link could not be made: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
invited.reset_link = Some(link);
|
||||
invited.link_expires_ms = expires;
|
||||
if let Some(map) = answer.responses.as_object_mut() {
|
||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||
}
|
||||
if let Ok(data) = serde_json::to_vec(&answer) {
|
||||
let _ = store.put(id, data.into()).await;
|
||||
}
|
||||
let responsible_for: Vec<String> = answer
|
||||
.responses
|
||||
.get("responsible_for")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
if responsible_for.is_empty() {
|
||||
crate::mail::on_invite(state, &invited).await;
|
||||
} else {
|
||||
crate::mail::on_responsible(state, &invited, &responsible_for).await;
|
||||
}
|
||||
tracing::info!(id, user = %invited.username, "an invite was sent again");
|
||||
}
|
||||
|
||||
/// A Kanidm account name from an email: the local part, lowercased,
|
||||
/// anything outside `[a-z0-9._-]` folded to `-`, led by a letter.
|
||||
pub fn username_for(email: &str) -> String {
|
||||
@@ -287,6 +597,79 @@ mod tests {
|
||||
assert_eq!(name_from_search("not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_runs_out_when_kanidm_says_in_either_spelling() {
|
||||
assert_eq!(expiry_ms(&serde_json::json!(1790793550)), Some(1790793550000));
|
||||
assert_eq!(expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), Some(1790769600000));
|
||||
assert_eq!(expiry_ms(&serde_json::json!(null)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_are_account_names_and_an_empty_group_is_null() {
|
||||
assert_eq!(members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#), vec!["kari", "ola"]);
|
||||
assert!(members_from("null").is_empty());
|
||||
assert!(members_from("nonsense").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_listed_credential_counts() {
|
||||
assert!(has_credentials_from(r#"{"creds":[{"type_":"PasswordMfa"}]}"#));
|
||||
assert!(!has_credentials_from(r#"{"creds":[]}"#));
|
||||
assert!(!has_credentials_from("null"));
|
||||
}
|
||||
|
||||
fn invite(state: &str, created: bool, expires: Option<i64>) -> crate::answers::Answer {
|
||||
crate::answers::Answer {
|
||||
id: "a".into(),
|
||||
question_id: "/styret".into(),
|
||||
alternative: "Inviter".into(),
|
||||
responses: serde_json::json!({ "name": "Kari", "email": "kari@vel.no", "invited": {
|
||||
"username": "kari", "display_name": "Kari", "email": "kari@vel.no", "group": "styret",
|
||||
"reset_link": "https://id/ui/reset?token=x", "link_expires_ms": expires, "created": created,
|
||||
}}),
|
||||
state: state.into(),
|
||||
submitted_ms: 1_000,
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() {
|
||||
let day = LINK_SECONDS * 1000;
|
||||
// Still within the link's life, and nobody has seen them: wait.
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 4_999), None);
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 5_000), Some(INVITE_EXPIRED));
|
||||
// In is in, however long the link had left.
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 10), Some(INVITE_DONE));
|
||||
assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 9_000), Some(INVITE_DONE));
|
||||
// A record from before links carried their end: a day from when it was sent.
|
||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day - 1), None);
|
||||
assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day), Some(INVITE_EXPIRED));
|
||||
// Someone who had an account was only given a place: nothing to wait for.
|
||||
assert_eq!(invite_due(&invite("open", false, None), false, 10), Some(INVITE_DONE));
|
||||
// Only a waiting invite moves.
|
||||
assert_eq!(invite_due(&invite("expired", true, Some(5_000)), false, 9_000), None);
|
||||
assert_eq!(invite_due(&invite("done", true, Some(5_000)), true, 9_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invite_is_theirs_by_account_or_by_address() {
|
||||
let invited: Invited = serde_json::from_value(invite("open", true, None).responses["invited"].clone()).unwrap();
|
||||
let user = |username: &str, email: Option<&str>| crate::auth::User {
|
||||
sub: "s".into(),
|
||||
username: username.into(),
|
||||
display_name: String::new(),
|
||||
groups: vec![],
|
||||
email: email.map(String::from),
|
||||
};
|
||||
assert!(is_for(&invited, &user("kari", None)));
|
||||
assert!(is_for(&invited, &user("kari.n", Some("Kari@Vel.no"))));
|
||||
assert!(!is_for(&invited, &user("ola", Some("ola@vel.no"))));
|
||||
assert!(!is_for(&invited, &user("ola", None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_person_answer_means_nobody_there() {
|
||||
assert_eq!(person_lookup_found(200, "null"), Some(false));
|
||||
|
||||
+184
-31
@@ -16,20 +16,30 @@
|
||||
//! 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.
|
||||
//! `SEED_GROUP` seeds the new pair.
|
||||
//!
|
||||
//! **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`).
|
||||
//! question, so they get an account: at start, and again whenever the
|
||||
//! content changes, 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`).
|
||||
//!
|
||||
//! **And no longer.** Someone the content has stopped naming leaves
|
||||
//! `RESPONSIBLE_GROUP` in the same pass, if portal is who put them
|
||||
//! there. Their account is theirs and stays.
|
||||
//!
|
||||
//! Both run side by side and try again a minute apart, twenty times:
|
||||
//! Kanidm and gdo may still be coming up alongside portal. Every
|
||||
//! invite made here is put on file in `portal_invites` like a desk's,
|
||||
//! so a link that runs out unused shows up where someone can send it
|
||||
//! again.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
@@ -92,10 +102,58 @@ pub fn contact_email(contact: &str) -> Option<String> {
|
||||
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
|
||||
}
|
||||
|
||||
/// The first person in, then everyone the content names as responsible.
|
||||
/// A failed attempt is tried again this many times, this far apart:
|
||||
/// Kanidm and gdo may still be coming up beside portal.
|
||||
const ATTEMPTS: u32 = 20;
|
||||
const APART: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// One pass over the responsible people at a time: a reload may arrive
|
||||
/// while the pass from the start is still at it, and two at once would
|
||||
/// both find the same person missing and invite them twice.
|
||||
static PASS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// The first person in and everyone the content names as responsible,
|
||||
/// side by side, so neither waits on the other's retries - and the
|
||||
/// responsible again whenever the content changes, for the life of the
|
||||
/// process.
|
||||
pub async fn run(state: crate::server::AppState) {
|
||||
seed_admin(&state).await;
|
||||
responsible(&state).await;
|
||||
// Subscribed before the first pass, so content that changes while
|
||||
// it runs is not missed.
|
||||
let reloads = state.nats.subscribe(crate::content::CONTENT_RELOADED_SUBJECT).await;
|
||||
tokio::join!(seed_admin(&state), responsible_until_done(&state), async {
|
||||
let mut reloads = match reloads {
|
||||
Ok(reloads) => reloads,
|
||||
Err(e) => {
|
||||
tracing::error!("could not listen for changed content: {e} - a new responsible person waits for the next start");
|
||||
return;
|
||||
}
|
||||
};
|
||||
use futures::StreamExt;
|
||||
while reloads.next().await.is_some() {
|
||||
responsible_until_done(&state).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Passes over the responsible people until one leaves nobody behind.
|
||||
/// Each pass reads the content afresh, so what it retries is what the
|
||||
/// content says by then.
|
||||
async fn responsible_until_done(state: &crate::server::AppState) {
|
||||
for attempt in 1..=ATTEMPTS {
|
||||
let left = {
|
||||
let _one_at_a_time = PASS.lock().await;
|
||||
responsible(state).await
|
||||
};
|
||||
if left == 0 {
|
||||
return;
|
||||
}
|
||||
if attempt == ATTEMPTS {
|
||||
tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again");
|
||||
return;
|
||||
}
|
||||
tracing::warn!(left, attempt, "some responsible people could not be set up, trying again in a minute");
|
||||
tokio::time::sleep(APART).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
|
||||
@@ -111,35 +169,64 @@ async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jets
|
||||
}
|
||||
}
|
||||
|
||||
async fn responsible(state: &crate::server::AppState) {
|
||||
fn seed_admin_email() -> Option<String> {
|
||||
std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@'))
|
||||
}
|
||||
|
||||
/// Who portal put in the responsible group and the content no longer
|
||||
/// names: their key in the seed bucket, and the account. The first
|
||||
/// person is never among them - with `SEED_GROUP` the same group, their
|
||||
/// place there is from the seed, not from a page.
|
||||
pub fn no_longer_responsible(
|
||||
on_file: &[(String, crate::people::Invited)],
|
||||
people: &BTreeMap<String, (String, Vec<String>)>,
|
||||
seed_admin: Option<&str>,
|
||||
) -> Vec<(String, crate::people::Invited)> {
|
||||
on_file
|
||||
.iter()
|
||||
.filter(|(_, invited)| {
|
||||
let email = invited.email.trim().to_lowercase();
|
||||
!people.contains_key(&email) && seed_admin != Some(email.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One pass: an account for everyone the content names, and out of the
|
||||
/// group for everyone it has stopped naming. Returns how many people
|
||||
/// it could not finish with.
|
||||
async fn responsible(state: &crate::server::AppState) -> usize {
|
||||
let people = responsible_people(&state.questions.load());
|
||||
if people.is_empty() {
|
||||
return;
|
||||
let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
|
||||
if people.is_empty() && group.is_none() {
|
||||
return 0;
|
||||
}
|
||||
let Some(kanidm) = crate::people::Kanidm::from_env() else {
|
||||
if !people.is_empty() {
|
||||
tracing::info!(people = people.len(), "the content names responsible people, but invites are not configured (KANIDM_API_TOKEN) - no accounts made");
|
||||
return;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
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"));
|
||||
let Some(store) = seed_bucket(state).await else { return 0 };
|
||||
let mut left = 0;
|
||||
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 };
|
||||
let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() };
|
||||
// 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 {
|
||||
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,
|
||||
Ok(false) => kanidm.account(&name, email).await,
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
};
|
||||
@@ -150,15 +237,52 @@ async fn responsible(state: &crate::server::AppState) {
|
||||
let _ = store.put(&key, bytes.into()).await;
|
||||
}
|
||||
if invited.created {
|
||||
crate::mail::on_responsible(state, &invited, &questions).await;
|
||||
crate::people::record(state, &invited, questions).await;
|
||||
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}"),
|
||||
Err(e) => {
|
||||
left += 1;
|
||||
tracing::warn!(%email, "could not set up a responsible person: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Being responsible ends when the content stops saying so. The
|
||||
// account stays - it is theirs - but the place in the group, and
|
||||
// whatever else that group opens, goes with the page. Only what
|
||||
// portal gave: someone who was in the group before is left there.
|
||||
let Some(group) = group else { return left };
|
||||
let prefix = seed_key("", &group);
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut on_file = Vec::new();
|
||||
for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(invited) = serde_json::from_slice::<crate::people::Invited>(&bytes) else { continue };
|
||||
on_file.push((key, invited));
|
||||
}
|
||||
for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) {
|
||||
if !invited.already_member {
|
||||
if let Err(e) = kanidm.remove_member(&group, &invited.username).await {
|
||||
left += 1;
|
||||
tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Forgotten, so that being named again is being set up again.
|
||||
let _ = store.purge(&key).await;
|
||||
tracing::info!(user = %invited.username, group, removed = !invited.already_member, "the content no longer names this person as responsible");
|
||||
}
|
||||
left
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -190,15 +314,14 @@ async fn seed_admin(state: &crate::server::AppState) {
|
||||
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 => {
|
||||
Err(e) if attempt < ATTEMPTS => {
|
||||
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;
|
||||
tokio::time::sleep(APART).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(group, "seeding the first person failed for good: {e}");
|
||||
@@ -212,6 +335,9 @@ async fn seed_admin(state: &crate::server::AppState) {
|
||||
tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again");
|
||||
}
|
||||
}
|
||||
if invited.created {
|
||||
crate::people::record(state, &invited, &[]).await;
|
||||
}
|
||||
crate::mail::on_invite(state, &invited).await;
|
||||
tracing::info!(group, user = %invited.username, created = invited.created, "the first person is invited");
|
||||
}
|
||||
@@ -250,6 +376,33 @@ mod tests {
|
||||
assert!(most_privileged_group(&none).is_none());
|
||||
}
|
||||
|
||||
fn on_file(email: &str, already_member: bool) -> (String, crate::people::Invited) {
|
||||
(
|
||||
seed_key(email, "redaktor"),
|
||||
crate::people::Invited {
|
||||
username: email.split('@').next().unwrap().to_string(),
|
||||
display_name: String::new(),
|
||||
email: email.to_string(),
|
||||
group: "redaktor".to_string(),
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created: true,
|
||||
already_member,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person() {
|
||||
let people: BTreeMap<String, (String, Vec<String>)> = [("kari@vel.no".to_string(), ("Kari".to_string(), vec!["Hva?".to_string()]))].into_iter().collect();
|
||||
let filed = vec![on_file("kari@vel.no", false), on_file("Ola@vel.no", false), on_file("leder@vel.no", false)];
|
||||
let gone = no_longer_responsible(&filed, &people, Some("leder@vel.no"));
|
||||
assert_eq!(gone.iter().map(|(_, i)| i.username.as_str()).collect::<Vec<_>>(), vec!["Ola"]);
|
||||
assert_eq!(gone[0].0, "redaktor.ola_vel_no");
|
||||
// Nobody named at all: everyone portal put there leaves.
|
||||
assert_eq!(no_longer_responsible(&filed, &BTreeMap::new(), None).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_named_responsible_is_found_once_by_their_address() {
|
||||
let mut qs: HashMap<String, Question> = HashMap::new();
|
||||
|
||||
@@ -260,6 +260,10 @@ pub async fn callback(
|
||||
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?;
|
||||
let target = redirect.filter(|r| is_safe_redirect(r)).unwrap_or_else(|| "/".to_string());
|
||||
|
||||
// Being here is what an invite was for (people.rs): off the
|
||||
// request, since nobody signing in should wait on a desk's list.
|
||||
tokio::spawn(crate::people::on_signed_in(state.clone(), user.clone()));
|
||||
|
||||
tracing::info!(user = %user.username, redirect = %target, "signed in");
|
||||
Ok(Redirect::to(&target))
|
||||
}
|
||||
|
||||
@@ -386,6 +386,45 @@ main.not-found {
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.suggest-edit h3 {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-dim);
|
||||
margin: 1.4rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.suggest-edit-mine {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.suggest-edit-mine li {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 0.06rem solid var(--line);
|
||||
}
|
||||
|
||||
.suggest-edit-state {
|
||||
flex: none;
|
||||
min-width: 6.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.suggest-edit-state[data-state="done"] {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.suggest-edit-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.question-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user