Compare commits

...
11 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 48a1a58d01 Release 0.5.4
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m10s
2026-09-29 21:11:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 72b7945ab4 Changelog: 0.5.4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:11:11 +02:00
bl a4c39e5ebc Merge pull request 'Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible' (#2) from seed-admin into main
Test / test (push) Successful in 55s
2026-09-29 19:10:40 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ef4789f9b2 Invites and suggestions are cases; the responsible follow the content
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
2026-09-29 21:00:10 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 7a50ce77b2 Docs: getting started says what is retried and what waits for the next start
Test / test (pull_request) Successful in 51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 18:03:51 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 641c5f1bd3 Docs: getting started, bringing your people in
Test / test (pull_request) Successful in 51s
From an empty Kanidm to everyone at their own desk: what in the content
decides who needs an account, the four things Kanidm needs for portal,
the first person, what happens at start, how everyone after comes in
from the desks, what the people who ask the questions can do, and what
each failure looks like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 18:02:02 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 0886869607 Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible
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
2026-09-29 15:44:20 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 bd174224fe Docs: vel.klingenbergbygg.no runs v0.5.3, from its own podman stack
Test / test (push) Failing after 56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 13:38:32 +02:00
Bendik Aagaard Lynghaug 46d19e6d9d Release 0.5.3
Test / test (push) Successful in 51s
Publish release / publish (push) Successful in 2m59s
2026-09-29 13:30:22 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9229fc1674 Changelog: 0.5.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 13:30:12 +02:00
bl c9d52c6005 Merge pull request 'A way down the tree from every page' (#1) from down-the-tree into main
Test / test (push) Successful in 54s
2026-09-29 11:29:57 +00:00
19 changed files with 1671 additions and 37 deletions
+12 -2
View File
@@ -4,7 +4,17 @@ 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 ## 0.5.4 (2026-09-29)
- 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)
- 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.
@@ -362,4 +372,4 @@ hosts now, since kasse's runner deploys its own instances.
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 | | redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 | | westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 | | portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 |
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.5.2 | | vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.3 |
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.5.2" version = "0.5.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.5.2" version = "0.5.4"
edition = "2021" edition = "2021"
[lib] [lib]
+1
View File
@@ -6,6 +6,7 @@ engine does with it.
| | | | | |
|---|---| |---|---|
| [Getting started](getting-started.md) | Bringing your people in: from an empty Kanidm to everyone at their own desk, from what the content says. |
| [Architecture](architecture.md) | What the engine is made of and where each piece lives in the source. | | [Architecture](architecture.md) | What the engine is made of and where each piece lives in the source. |
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. | | [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. | | [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. |
+200
View File
@@ -0,0 +1,200 @@
# Getting started: bringing your people in
A site is run by a handful of people: whoever answers for each question
it asks, and whoever works each desk. This is how they get in - from
an empty Kanidm to everyone signed in at their own desk - without
anyone handing out passwords or running an admin tool per person.
The idea in one line: **the content says who is involved, and portal
makes their accounts.** A page names who is responsible for it; a
directory is gated on a group; a desk may invite into groups. Portal
reads that and does the rest at start.
## What you need
- A content repo, and a portal instance running it
([Operations](operations.md) has adding a site).
- A Kanidm of the site's own. Group names below are exactly the ones
the content uses, with no prefix; that is only safe when the Kanidm
serves this site alone.
- Mail: `mail: { from }` in `site.yaml`, and gdo on the host. Without
it accounts are still made, but nobody hears about them.
## 1. Say who is involved, in the content
Three things in the content decide who needs an account:
| content | who | what they get |
|---|---|---|
| `responsible: { name, contact }` on a page | the person answering for that question | an account, and a mail saying they are listed as responsible for asking it |
| `qualifies: styret` on a page or `_section.yaml` | members of that group | the pages and desks under it |
| `invite: { group: kasserer }` on a desk page | the desk's own group | the right to bring people into `kasserer` |
The desk whose group may invite into the most groups is the site's most
privileged one; on a vel that is the board. Portal works that out from
the content (see step 4).
## 2. Set up Kanidm for portal
Portal needs four things from Kanidm. The
[tomtervel/infrastructure](https://prosjekt.klingenbergbygg.no/tomtervel/infrastructure)
repo's `kanidm-setup.sh` does all of them and is a good starting point;
by hand, as `idm_admin`:
```sh
K="kanidm -D idm_admin -H https://id.example.no"
# An onboarding account: may create people and issue a first password
# link, nothing more. pii_read lets an invite find someone who already
# has an account by their email instead of making a second one.
$K service-account create portal-onboarding "Portal onboarding" idm_admin
$K group add-members idm_people_on_boarding portal-onboarding
$K group add-members idm_people_pii_read portal-onboarding
# One group per `qualifies:` in the content, managed by that account:
# adding a member to a group takes the right to manage it.
for g in styret kasserer trafikk; do
$K group create "$g" portal-onboarding
done
# The sign-in client. Everyone in this Kanidm may sign in; what they
# see comes from their groups, in the `groups` claim.
$K system oauth2 create portal "My site" https://site.example.no
$K system oauth2 add-redirect-url portal https://site.example.no/auth/callback
$K system oauth2 update-scope-map portal idm_all_persons openid profile email
for g in styret kasserer trafikk; do
$K system oauth2 update-claim-map portal groups "$g" "$g"
done
$K system oauth2 update-claim-map-join portal groups array
# The onboarding token, read-write, shown once: into portal's env.
$K service-account api-token generate portal-onboarding portal --readwrite
```
Then portal's environment:
| variable | value |
|---|---|
| `KANIDM_URL` | `https://id.example.no` |
| `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | the client above (`show-basic-secret`) |
| `KANIDM_API_TOKEN` | the onboarding token |
## 3. Name the first person
Someone has to be first: every later person is invited from a desk,
and on an empty Kanidm there is no desk anyone can open yet.
| variable | value |
|---|---|
| `SEED_ADMIN_EMAIL` | the first person's address |
| `SEED_ADMIN_NAME` | their name, as it should appear (optional) |
| `SEED_GROUP` | a group to put them in, if not the most privileged one (optional) |
| `RESPONSIBLE_GROUP` | a group every responsible person joins, e.g. `redaktor` (optional) |
## 4. Start portal
At start, portal:
1. **Invites the first person** into `SEED_GROUP`, or the most
privileged group, and mails them the one-time link. Once: it is
remembered in the `portal_seed` bucket, so restarts and upgrades
never send a second link.
2. **Makes an account for everyone named responsible** who has none,
and mails them that they are listed as responsible for asking those
questions, with their link. With `RESPONSIBLE_GROUP` they all join
it; someone who already had an account is added without a mail.
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:
- **At a desk**, whoever may invite fills in a name and an address.
Portal makes the account, adds them to the group, and mails the link.
- **A decision can be the invitation.** A state with `grants: group`
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
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, 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:
a second OAuth2 client scoped to that group alone.
## When it does not work
| what you see | why |
|---|---|
| an invite fails with `403` setting `mail` | a portal before 0.5.4 set the address in a second call; the onboarding account may create but not modify |
| an invite fails with `404` adding to a group | the group is not managed by `portal-onboarding`: `group set-entry-manager <group> portal-onboarding` |
| `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 |
+2
View File
@@ -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, 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 | | `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 |
+5 -1
View File
@@ -167,7 +167,7 @@ pub async fn transition_answers(
/// transition retries. Keeps "zap the whole event stream" a safe /// transition retries. Keeps "zap the whole event stream" a safe
/// operation instead of one that strands every pre-wipe record. /// operation instead of one that strands every pre-wipe record.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
async fn transition_or_reseed( pub(crate) async fn transition_or_reseed(
js: &async_nats::jetstream::Context, js: &async_nats::jetstream::Context,
schema: &crate::aggregates::AggregateSchema, schema: &crate::aggregates::AggregateSchema,
item_id: &str, item_id: &str,
@@ -291,6 +291,10 @@ async fn apply_transition(
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await; 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; 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 // The decision extends the DAG as a child of the answer's own
// submission hash, published like any other answer. // submission hash, published like any other answer.
+232
View File
@@ -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,99 @@ 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)
}
/// 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, 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();
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>
<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>
}
}
#[component] #[component]
fn ResponsibleNote(responsible: Responsible) -> impl IntoView { fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
let (user, domain) = responsible let (user, domain) = responsible
@@ -2433,6 +2533,119 @@ 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. 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>();
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();
// 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 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()))?;
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,
};
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")] #[server(endpoint = "submit_answer")]
pub async fn submit_answer( pub async fn submit_answer(
question_id: String, question_id: String,
@@ -2681,3 +2894,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(""))), ""));
}
}
+4
View File
@@ -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";
+8
View File
@@ -171,6 +171,13 @@ pub async fn load_questions_from_gitea(
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload"; 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 /// Runs for the life of the process: re-fetches `repo_url`/`branch` and
/// atomically swaps both `questions` and `aggregates` on every /// atomically swaps both `questions` and `aggregates` on every
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together, /// `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)); aggregates.store(std::sync::Arc::new(loaded_aggregates));
site.store(std::sync::Arc::new(loaded_site)); site.store(std::sync::Arc::new(loaded_site));
tracing::info!(count, "reloaded content"); tracing::info!(count, "reloaded content");
let _ = nats.publish(CONTENT_RELOADED_SUBJECT, "".into()).await;
} }
Err(e) => { Err(e) => {
tracing::error!(error = %e, "content reload failed, keeping last-good content"); tracing::error!(error = %e, "content reload failed, keeping last-good content");
+20 -1
View File
@@ -36,6 +36,12 @@ pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
/// runtime's sweeper (`announce.rs`) is what moves the records. /// runtime's sweeper (`announce.rs`) is what moves the records.
pub const EVENTS_BUCKET: &str = "portal_events"; 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")] #[cfg(feature = "ssr")]
const BUILTIN_AGGREGATES_YAML: &str = "\ const BUILTIN_AGGREGATES_YAML: &str = "\
aggregates: aggregates:
@@ -53,9 +59,22 @@ aggregates:
initial: open initial: open
states: states:
open: { event: invited } open: { event: invited }
expired: { event: expired }
done: { event: done } done: { event: done }
transitions: 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 /// Adds the runtime's own state graphs to a content repo's. Content
+27
View File
@@ -34,6 +34,15 @@ 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", "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", ("en", "the_start") => "the start",
("no", "also_worth_asking") => "Også verdt å spørre", ("no", "also_worth_asking") => "Også verdt å spørre",
@@ -54,6 +63,15 @@ 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", "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", ("no", "the_start") => "starten",
_ => return None, _ => return None,
@@ -89,6 +107,15 @@ mod tests {
"announcements", "announcements",
"down_to", "down_to",
"the_start", "the_start",
"suggest_edit",
"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("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}");
+3
View File
@@ -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")]
+221
View File
@@ -265,6 +265,11 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow
/// failed request. /// failed request.
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) { 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 { 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; return;
}; };
let site = state.site.load(); let site = state.site.load();
@@ -301,6 +306,196 @@ 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
/// 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 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(),
"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}");
}
}
/// 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.
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}`,
@@ -367,6 +562,32 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
mod tests { mod tests {
use super::*; 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] #[test]
fn templates_take_fields_and_leave_stray_braces_alone() { fn templates_take_fields_and_leave_stray_braces_alone() {
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null }); let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
+6
View File
@@ -108,6 +108,12 @@ 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 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 // 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
+430 -31
View File
@@ -8,14 +8,35 @@
//! Talks to Kanidm's REST API with a service-account token //! Talks to Kanidm's REST API with a service-account token
//! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The //! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The
//! service account belongs to `idm_people_on_boarding`, which may //! service account belongs to `idm_people_on_boarding`, which may
//! create persons and issue first-credential resets and nothing else, //! create persons and issue credential resets for ordinary people and
//! so a leaked token cannot touch existing credentials or admins. //! 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")] #![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const INVITES_BUCKET: &str = "portal_invites"; pub const INVITES_BUCKET: &str = "portal_invites";
pub const INVITED_SUBJECT: &str = "portal.people.invited"; 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. /// What an invite produced: the account, and the link that sets it up.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -29,6 +50,42 @@ pub struct Invited {
pub reset_link: Option<String>, pub reset_link: Option<String>,
pub link_expires_ms: Option<i64>, pub link_expires_ms: Option<i64>,
pub created: bool, 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. /// 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()] } })), Some(serde_json::json!({ "filter": { "eq": ["mail", email.trim().to_lowercase()] } })),
) )
.await?; .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"); tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead");
return Ok(None); return Ok(None);
} }
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: searching people by mail: http {status}");
}
Ok(name_from_search(&text)) Ok(name_from_search(&text))
} }
@@ -109,53 +172,110 @@ 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())
}
/// 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 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 let mut already_member = false;
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username]))) if let Some(group) = group {
.await?; // Someone new is in no group. For anyone else, whether they
if !(200..300).contains(&status) { // were here before is what says whose the membership is; a
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}"); // failed read says "not before", which errs toward portal
} // tidying up after itself.
let (reset_link, link_expires_ms) = if created { already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username));
// 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 let (status, text) = self
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/86400"), None) .call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
.await?; .await?;
if !(200..300).contains(&status) { if !(200..300).contains(&status) {
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}"); anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
} }
#[derive(Deserialize)] }
struct Intent { let (reset_link, link_expires_ms) = if created {
token: String, let (link, expires) = self.reset_link(&username).await?;
#[serde(default)] (Some(link), expires)
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)
} else { } else {
(None, None) (None, None)
}; };
@@ -163,14 +283,220 @@ 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,
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, /// A Kanidm account name from an email: the local part, lowercased,
/// anything outside `[a-z0-9._-]` folded to `-`, led by a letter. /// anything outside `[a-z0-9._-]` folded to `-`, led by a letter.
pub fn username_for(email: &str) -> String { pub fn username_for(email: &str) -> String {
@@ -271,6 +597,79 @@ mod tests {
assert_eq!(name_from_search("not json"), None); 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] #[test]
fn a_null_person_answer_means_nobody_there() { fn a_null_person_answer_means_nobody_there() {
assert_eq!(person_lookup_found(200, "null"), Some(false)); assert_eq!(person_lookup_found(200, "null"), Some(false));
+425
View File
@@ -0,0 +1,425 @@
//! 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.
//!
//! **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 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};
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)
}
/// 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) {
// 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> {
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
}
},
}
}
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());
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 0;
};
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.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 {
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::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) => {
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 {
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;
};
let mut attempt = 0;
let invited = loop {
attempt += 1;
match kanidm.invite(&name, &email, &group).await {
Ok(invited) => break invited,
Err(e) if attempt < ATTEMPTS => {
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
tokio::time::sleep(APART).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");
}
}
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");
}
#[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());
}
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();
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"));
}
}
+6
View File
@@ -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.
@@ -258,6 +260,10 @@ pub async fn callback(
let redirect: Option<String> = session.remove(REDIRECT_KEY).await.map_err(internal)?; 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()); 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"); tracing::info!(user = %user.username, redirect = %target, "signed in");
Ok(Redirect::to(&target)) Ok(Redirect::to(&target))
} }
+67
View File
@@ -358,6 +358,73 @@ 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;
}
.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 { .question-nav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;