Compare commits
@@ -0,0 +1,45 @@
|
|||||||
|
name: docs
|
||||||
|
|
||||||
|
# docs/ as a site at portal.uhhm.no: built with mdBook and put to
|
||||||
|
# git-pages on every push to main that touches it (the uhhm pages
|
||||||
|
# pattern, uhhm/infrastructure PAGES.md). The changelog rides along as
|
||||||
|
# the book's last page. Authorized by the _git-pages-forge-allowlist
|
||||||
|
# TXT record for portal.uhhm.no plus a token the forge confirms may
|
||||||
|
# push to this repo (the GITEA_TOKEN secret).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths: ["docs/**", "book.toml", "CHANGELOG.md", ".gitea/workflows/docs.yml"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
MDBOOK_VERSION: v0.5.4
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: bare
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: build the book
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
tool=/var/local/cargo-target/mdbook-$MDBOOK_VERSION
|
||||||
|
if [ ! -x "$tool/mdbook" ]; then
|
||||||
|
mkdir -p "$tool"
|
||||||
|
curl -sSfL "https://github.com/rust-lang/mdBook/releases/download/$MDBOOK_VERSION/mdbook-$MDBOOK_VERSION-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$tool"
|
||||||
|
fi
|
||||||
|
cp CHANGELOG.md docs/changelog.md
|
||||||
|
"$tool/mdbook" build
|
||||||
|
test -f target/book/index.html
|
||||||
|
test -f target/book/memberships.html
|
||||||
|
|
||||||
|
- name: deploy to portal.uhhm.no
|
||||||
|
run: |
|
||||||
|
tar -czf site.tar.gz -C target/book .
|
||||||
|
curl --fail -sS -X PUT "https://portal.uhhm.no/" \
|
||||||
|
-H "Content-Type: application/x-tar+gzip" \
|
||||||
|
-H "Forge-Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||||
|
--data-binary @site.tar.gz \
|
||||||
|
-w "deployed: %{http_code}\n"
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
.claude/
|
.claude/
|
||||||
|
docs/changelog.md
|
||||||
|
|||||||
+36
-2
@@ -4,6 +4,40 @@ 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).
|
||||||
|
|
||||||
|
## 0.5.9 (2026-09-30)
|
||||||
|
|
||||||
|
- With memberships as the people backend and a Kanidm token still set, an invite makes the Kanidm account as well (no group, the one-time link in the mail): Kanidm makes people, portal makes members, and a site can switch to memberships before its sign-in provider is one everyone already has.
|
||||||
|
|
||||||
|
## 0.5.8 (2026-09-30)
|
||||||
|
|
||||||
|
- Fixed: 0.5.7 asked every provider for the `phoneNumber` scope, and Kanidm denies a sign-in that asks for a scope the person does not hold, so nobody could sign in. The scope is asked for only with `OIDC_EXTRA_SCOPES=phoneNumber` (Vipps). 0.5.7 must not be deployed on a Kanidm site.
|
||||||
|
|
||||||
|
## 0.5.7 (2026-09-30)
|
||||||
|
|
||||||
|
- Memberships: groups kept by portal itself (`src/memberships.rs`, `docs/memberships.md`). A membership is a case in the built-in `portal_memberships` bucket - `invited` by a desk, a `grants`, the seed or the responsible pass; `active` from the person's first sign-in, when the provider's subject, phone number or address matches; `removed` by a desk. A person is their phone number first (`+4791234567`, `PHONE_COUNTRY` for bare numbers), their address second, the provider's subject third, and one record per group and person however it was keyed. At sign-in the active memberships join the `groups` claim, so Cedar sees the same parents as before. `PEOPLE_BACKEND=memberships` switches a site; unset, a `KANIDM_API_TOKEN` means Kanidm and no token means memberships. `SEED_ADMIN_PHONE` seeds by number. The sign-in asks for the `phoneNumber` scope and reads `phone_number` from userinfo.
|
||||||
|
- The NATS access service answers for a person named by `sub`, `phone` or `email` with no groups listed: their memberships are looked up first.
|
||||||
|
- `who_for` (the person a `grants` is about) takes `navn` as well as `name`, and a phone number in place of an address.
|
||||||
|
- The invite mail for someone who needs no link says where to sign in.
|
||||||
|
|
||||||
|
## 0.5.6 (2026-09-30)
|
||||||
|
|
||||||
|
- A group may hold a page's responsibility: `responsible: { name, contact, group: styret }`. No account or mail for the inbox, and any signed-in member of the group gets the suggest-a-change card. Before this, a board's shared address was onboarded as a person.
|
||||||
|
|
||||||
|
## 0.5.5 (2026-09-30)
|
||||||
|
|
||||||
|
- One record answers another, from a list. `link` on a resource puts a link on every listed record to a page about it (`{id}`, `{field}`), a state mail's `to` may be a field of the record this one belongs to (`{notice.email}`, and `{notice.field}` in the text), and `value: "{user.email}"` fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with "answer this", the answer page's form carries the notice's id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through.
|
||||||
|
- The way down the tree is the line at the foot of the page and nothing else. The arrow fixed at the left edge, which 0.5.3 added beside it, is gone: it followed the reader down every page to offer something the foot already does.
|
||||||
|
|
||||||
|
## 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)
|
## 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.
|
||||||
@@ -325,7 +359,7 @@ content repos pin the tag they run (see the end of this file).
|
|||||||
- README: what the engine provides
|
- README: what the engine provides
|
||||||
- Feature icons: masked span painted by currentColor, not <img>
|
- Feature icons: masked span painted by currentColor, not <img>
|
||||||
- Responsible note: one line, same hue and size as the hero description
|
- Responsible note: one line, same hue and size as the hero description
|
||||||
- Responsible note as <small>: meta information, dimmer than the description
|
- Responsible note as `<small>`: meta information, dimmer than the description
|
||||||
- Responsible note back to the footer, above the question nav
|
- Responsible note back to the footer, above the question nav
|
||||||
- Bound inputs: a field that loads its value from a sibling-parameterized resource
|
- Bound inputs: a field that loads its value from a sibling-parameterized resource
|
||||||
- add some more width to our alternatives
|
- add some more width to our alternatives
|
||||||
@@ -362,4 +396,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.8 |
|
||||||
|
|||||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.5.3"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.5.3"
|
version = "0.5.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# The docs as a site: portal.uhhm.no, built by .gitea/workflows/docs.yml
|
||||||
|
# from docs/ with mdBook and put to git-pages (see uhhm/infrastructure
|
||||||
|
# PAGES.md). `mdbook serve` here previews it.
|
||||||
|
[book]
|
||||||
|
title = "portal"
|
||||||
|
description = "A Git repo of YAML becomes a running site: pages, desks, cases, people."
|
||||||
|
authors = ["uhhm"]
|
||||||
|
language = "en"
|
||||||
|
src = "docs"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
build-dir = "target/book"
|
||||||
|
create-missing = false
|
||||||
|
|
||||||
|
[output.html]
|
||||||
|
default-theme = "light"
|
||||||
|
preferred-dark-theme = "navy"
|
||||||
|
git-repository-url = "https://project.uhhm.no/uhhm/portal"
|
||||||
|
site-url = "/"
|
||||||
|
no-section-label = true
|
||||||
|
|
||||||
|
[output.html.search]
|
||||||
|
limit-results = 20
|
||||||
@@ -6,10 +6,12 @@ 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. |
|
||||||
| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. |
|
| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. |
|
||||||
|
| [Memberships](memberships.md) | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. |
|
||||||
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
|
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
|
||||||
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
|
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Summary
|
||||||
|
|
||||||
|
[portal](README.md)
|
||||||
|
|
||||||
|
# Using it
|
||||||
|
|
||||||
|
- [Getting started](getting-started.md)
|
||||||
|
- [Content reference](content-reference.md)
|
||||||
|
- [State machines](state-machines.md)
|
||||||
|
- [Access](access.md)
|
||||||
|
- [Memberships](memberships.md)
|
||||||
|
- [Operations](operations.md)
|
||||||
|
|
||||||
|
# How it is made
|
||||||
|
|
||||||
|
- [Architecture](architecture.md)
|
||||||
|
- [Filesystem routes](design/filesystem-routes.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Changelog](changelog.md)
|
||||||
@@ -4,6 +4,14 @@ Who may do what to what is compiled from content on every load into
|
|||||||
one Cedar policy set, and every server entry point asks that one
|
one Cedar policy set, and every server entry point asks that one
|
||||||
policy. Identity stays Kanidm's; portal only decides.
|
policy. Identity stays Kanidm's; portal only decides.
|
||||||
|
|
||||||
|
## Where the groups come from
|
||||||
|
|
||||||
|
The policy names groups; it does not hold them. A `User` entity's
|
||||||
|
parents are the person's groups, and those come from the sign-in: the
|
||||||
|
`groups` claim the provider sent, joined with the person's active
|
||||||
|
[memberships](memberships.md) when portal keeps them itself. Cedar
|
||||||
|
sees one list either way.
|
||||||
|
|
||||||
## What content compiles to
|
## What content compiles to
|
||||||
|
|
||||||
| content | rule |
|
| content | rule |
|
||||||
@@ -42,6 +50,11 @@ Other applications ask the same question over NATS request-reply on
|
|||||||
and context the server uses internally — so an automation acting as a
|
and context the server uses internally — so an automation acting as a
|
||||||
group gets exactly the answer a person in that group would.
|
group gets exactly the answer a person in that group would.
|
||||||
|
|
||||||
|
A principal may also name a person by `sub`, `phone` or `email` and
|
||||||
|
list no groups; portal then looks their [memberships](memberships.md)
|
||||||
|
up before deciding. That is what makes the policy usable for someone
|
||||||
|
who has never opened the site.
|
||||||
|
|
||||||
## The submitter
|
## The submitter
|
||||||
|
|
||||||
A person who sent a form has no account. Their credential is the link:
|
A person who sent a form has no account. Their credential is the link:
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ description: What we need from you.
|
|||||||
qualifies: case_officers # a Kanidm group may open this page
|
qualifies: case_officers # a Kanidm group may open this page
|
||||||
requires_chain: /start # or: an answer they must already have given
|
requires_chain: /start # or: an answer they must already have given
|
||||||
followup: true # keep it out of the nav until earned
|
followup: true # keep it out of the nav until earned
|
||||||
responsible: { name: Solveig, email: post@vestmark.example }
|
responsible: { name: Solveig, contact: post@vestmark.example }
|
||||||
|
# or a group's: no account is made for the inbox, and any signed-in
|
||||||
|
# member of the group may suggest a change to the page
|
||||||
|
responsible: { name: The board, contact: board@vestmark.example, group: board }
|
||||||
event: # this page announces an occasion
|
event: # this page announces an occasion
|
||||||
starts: 2026-10-14T18:00
|
starts: 2026-10-14T18:00
|
||||||
duration: 2h
|
duration: 2h
|
||||||
@@ -136,6 +139,10 @@ is a feature wasted: each one says something true about the choice.
|
|||||||
value: "{key}" # a preset; a dynamic page's segment fills it
|
value: "{key}" # a preset; a dynamic page's segment fills it
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`value: "{user.email}"` or `"{user.name}"` is the signed-in person's
|
||||||
|
own, and a field it fills is carried rather than asked: someone the
|
||||||
|
site knows is not asked who they are. A visitor gets the field, empty.
|
||||||
|
|
||||||
`type` is one of:
|
`type` is one of:
|
||||||
|
|
||||||
| type | what it is |
|
| type | what it is |
|
||||||
@@ -192,6 +199,19 @@ The other half is a listing that asks for the children:
|
|||||||
where: { field: case, equals: "{key}" }
|
where: { field: case, equals: "{key}" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
And the way there from a list: `link` puts a link on every listed
|
||||||
|
record to a page about it, with `{id}` for the record's id and
|
||||||
|
`{field}` for any of its fields. A notice on a public board links to
|
||||||
|
`/oppslag/{id}`, the dynamic page `/oppslag/[key]` whose form carries
|
||||||
|
the notice's id back in with `value: "{key}"`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resource:
|
||||||
|
source: { kind: kv, bucket: oppslag }
|
||||||
|
public: true
|
||||||
|
link: { to: "/oppslag/{id}", label: Jeg kan låne ut eller låne denne }
|
||||||
|
```
|
||||||
|
|
||||||
## A resource
|
## A resource
|
||||||
|
|
||||||
Live data, declared by content and read by the server. What a resource
|
Live data, declared by content and read by the server. What a resource
|
||||||
@@ -206,6 +226,7 @@ reads is never a client-supplied parameter.
|
|||||||
where: { field: case, equals: "{key}" }
|
where: { field: case, equals: "{key}" }
|
||||||
jq: ".[] | {name, url: .html_url}"
|
jq: ".[] | {name, url: .html_url}"
|
||||||
empty: Nothing decided yet.
|
empty: Nothing decided yet.
|
||||||
|
link: { to: "/oppslag/{id}", label: Answer this } # a page per record
|
||||||
transitions: # the desk's buttons
|
transitions: # the desk's buttons
|
||||||
- { from: complete, to: on_hearing, label: Send it out }
|
- { from: complete, to: on_hearing, label: Send it out }
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# 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. (Or no Kanidm at all: with
|
||||||
|
`PEOPLE_BACKEND=memberships` portal keeps the groups itself and any
|
||||||
|
OIDC provider that can say who someone is will do; see
|
||||||
|
[Memberships](memberships.md). The rest of this page is the Kanidm
|
||||||
|
way.)
|
||||||
|
- 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 |
|
||||||
|
| `responsible: { name, contact, group }` | a group answering for it | nothing: the inbox is not a person; the group's members have the desk, and each of them may suggest changes to the page |
|
||||||
|
| `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 |
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Memberships
|
||||||
|
|
||||||
|
Who belongs to which group, kept by portal itself.
|
||||||
|
|
||||||
|
A site's groups are named in its content: `qualifies` on a page,
|
||||||
|
`requires_group` on a list, `invite: { group }` on a desk, `grants` on
|
||||||
|
a state. Until now the groups themselves lived in Kanidm, and portal
|
||||||
|
read them from the sign-in token. With memberships they live in portal,
|
||||||
|
as records in the built-in `portal_memberships` bucket, and the
|
||||||
|
identity provider only has to say who someone is. Cedar sees no
|
||||||
|
difference: a person's active memberships are the parents of their
|
||||||
|
`User` entity, exactly as a `groups` claim was.
|
||||||
|
|
||||||
|
## A membership is a case
|
||||||
|
|
||||||
|
One record per group and person:
|
||||||
|
|
||||||
|
| state | means | how it gets there |
|
||||||
|
|---|---|---|
|
||||||
|
| `invited` | a place is held for them | a desk invite, a state that `grants`, the first person, someone the content names as responsible |
|
||||||
|
| `active` | they are in | they signed in, and the provider's subject, phone number or address matched the record |
|
||||||
|
| `removed` | a desk took them out | a button on the desk that lists the bucket; inviting them again reopens the record |
|
||||||
|
|
||||||
|
The record carries `name`, `group`, `by` (who or what put them there:
|
||||||
|
a username, `seed`, `responsible`, or the case as `bucket:id`), and
|
||||||
|
what is known of the person: `phone`, `email`, `sub`. At sign-in
|
||||||
|
everything the provider verified is written onto every record that
|
||||||
|
matched, so a record made from an address learns the phone number,
|
||||||
|
and the next sign-in matches on the subject before anything else.
|
||||||
|
|
||||||
|
Because it is a case, a desk lists it like any other bucket:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resource:
|
||||||
|
source: { kind: kv, bucket: portal_memberships }
|
||||||
|
requires_group: styret
|
||||||
|
transitions:
|
||||||
|
- { from: invited, to: removed, label: Withdraw }
|
||||||
|
- { from: active, to: removed, label: Take out }
|
||||||
|
- { from: removed, to: invited, label: Invite again }
|
||||||
|
```
|
||||||
|
|
||||||
|
## A person is their phone number first
|
||||||
|
|
||||||
|
A provider that verifies phone numbers (Vipps) makes the number the
|
||||||
|
surest thing a record can hold. So a person is known by, in order:
|
||||||
|
their phone number, their email address, the provider's subject. A
|
||||||
|
form that collects `phone`, `telefon`, `tel` or `mobil` gives the
|
||||||
|
first; `email` or `epost` the second. Numbers are kept as `+4791234567`
|
||||||
|
whatever way they were typed; a bare national number gets the site's
|
||||||
|
country (`PHONE_COUNTRY`, `+47` unless set). Addresses are lowercased.
|
||||||
|
|
||||||
|
A record matches a person on any one of the three, so a desk that
|
||||||
|
invited by address and a decision that granted by number are talking
|
||||||
|
about one person, and there is one record.
|
||||||
|
|
||||||
|
## Switching a site to memberships
|
||||||
|
|
||||||
|
```
|
||||||
|
PEOPLE_BACKEND=memberships
|
||||||
|
```
|
||||||
|
|
||||||
|
Unset, a `KANIDM_API_TOKEN` means Kanidm and no token means
|
||||||
|
memberships. With memberships:
|
||||||
|
|
||||||
|
- A desk invite writes an `invited` record and mails "log in at the
|
||||||
|
site, and your place is there". The provider makes people, portal
|
||||||
|
makes members: with Vipps Login everyone can already sign in. A
|
||||||
|
Kanidm still configured beside memberships (`KANIDM_API_TOKEN`)
|
||||||
|
makes the account too, with no group and the one-time link in the
|
||||||
|
mail, so a site can switch to memberships before its provider does.
|
||||||
|
- `grants` writes a record for the person the case is about, from the
|
||||||
|
form's name and phone number or address.
|
||||||
|
- `SEED_ADMIN_PHONE` or `SEED_ADMIN_EMAIL` writes the first person's.
|
||||||
|
- The people the content names as responsible get theirs in
|
||||||
|
`RESPONSIBLE_GROUP`, and lose it when the content stops naming them.
|
||||||
|
- The `groups` claim, if the provider sends one, still counts: a
|
||||||
|
person's groups are the union.
|
||||||
|
|
||||||
|
With `OIDC_EXTRA_SCOPES=phoneNumber` the sign-in asks the provider for
|
||||||
|
the number and reads `phone_number` from userinfo. Set it for Vipps;
|
||||||
|
not for Kanidm, which denies a sign-in that asks for a scope the
|
||||||
|
person does not hold. Without it, matching falls back to the address.
|
||||||
|
|
||||||
|
## Asking who belongs
|
||||||
|
|
||||||
|
Other applications ask portal the same access question they always
|
||||||
|
could, over NATS on `portal.access.<site>.may`. A principal that names
|
||||||
|
a person and lists no groups is looked up:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "principal": { "kind": "user", "sub": "vipps:abc", "phone": "912 34 567" },
|
||||||
|
"action": "transition",
|
||||||
|
"resource": { "kind": "bucket", "name": "hazards" },
|
||||||
|
"context": { "from": "open", "to": "fixed" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
The answer is Cedar's, with the rule ids that granted it, for a person
|
||||||
|
who may never have opened the site. That is the whole authorization
|
||||||
|
engine: the provider says who, memberships say what they belong to,
|
||||||
|
the policy compiled from content says what they may do.
|
||||||
|
|
||||||
|
## What Kanidm still does
|
||||||
|
|
||||||
|
For a site on Kanidm nothing changes: invites make accounts and add to
|
||||||
|
Kanidm groups, and `portal_invites` tracks the one-time links. The two
|
||||||
|
backends can be swapped by the one variable; the content and the
|
||||||
|
policy are the same either way.
|
||||||
+14
-1
@@ -69,7 +69,12 @@ values.
|
|||||||
| `NATS_URL` | events, projections, sessions, content reload, access questions |
|
| `NATS_URL` | events, projections, sessions, content reload, access questions |
|
||||||
| `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 |
|
| `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); unset, Kanidm when `KANIDM_API_TOKEN` is set, memberships otherwise |
|
||||||
|
| `KANIDM_API_TOKEN` | invites and `grants:` through Kanidm |
|
||||||
|
| `PHONE_COUNTRY` | the calling code a bare phone number gets, `+47` unless set |
|
||||||
|
| `OIDC_EXTRA_SCOPES` | scopes to ask the provider for besides `openid profile email`: `phoneNumber` for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold |
|
||||||
|
| `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PHONE`, `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 |
|
||||||
@@ -125,6 +130,14 @@ then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
|
|||||||
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
|
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
|
||||||
fails closed rather than appearing to work and storing nowhere.
|
fails closed rather than appearing to work and storing nowhere.
|
||||||
|
|
||||||
|
## The docs site
|
||||||
|
|
||||||
|
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
|
||||||
|
with mdBook (`book.toml`, `docs/SUMMARY.md`) by `.gitea/workflows/docs.yml`
|
||||||
|
on every push to main that touches them, and put to git-pages the way
|
||||||
|
every static site on uhhm.no is (uhhm/infrastructure `PAGES.md`). The
|
||||||
|
changelog is the book's last page. `mdbook serve` previews it locally.
|
||||||
|
|
||||||
## Who deploys what
|
## Who deploys what
|
||||||
|
|
||||||
Both hosts deploy from CI now, and a rollout is the same commit either
|
Both hosts deploy from CI now, and a rollout is the same commit either
|
||||||
|
|||||||
+13
-3
@@ -62,6 +62,13 @@ as text with an HTML alternative. `to: submitter` means the record's
|
|||||||
`email` field, and the lint then requires every form filling that
|
`email` field, and the lint then requires every form filling that
|
||||||
bucket to collect a required `email`.
|
bucket to collect a required `email`.
|
||||||
|
|
||||||
|
A record that belongs to another (a `type: record` field, say
|
||||||
|
`notice`) can write to that record's people: `to: "{notice.email}"`
|
||||||
|
goes to whoever posted the notice, and `{notice.hva}` in the body is
|
||||||
|
what they posted. The lint requires every form filling the bucket to
|
||||||
|
carry that field. This is how an answer to a notice on a board reaches
|
||||||
|
the neighbour who put it there, without anyone in between.
|
||||||
|
|
||||||
Portal renders it and publishes on `portal.mail.send`. gdo delivers
|
Portal renders it and publishes on `portal.mail.send`. gdo delivers
|
||||||
it, and a reply comes back on `portal.mail.received` as a note beside
|
it, and a reply comes back on `portal.mail.received` as a note beside
|
||||||
the record. A note never moves a case.
|
the record. A note never moves a case.
|
||||||
@@ -80,9 +87,12 @@ also need somebody to remember to invite them afterwards.
|
|||||||
|
|
||||||
The lint requires every form filling that bucket to collect a required
|
The lint requires every form filling that bucket to collect a required
|
||||||
`name` and a required `email`, because by the time a desk presses
|
`name` and a required `email`, because by the time a desk presses
|
||||||
approve there is nobody left to ask. An instance with no Kanidm
|
approve there is nobody left to ask. With memberships as the people
|
||||||
configured grants nothing and says so in the log; the transition
|
backend a phone number does as well as an address, and the grant is a
|
||||||
itself always stands.
|
record in `portal_memberships` rather than a Kanidm call (see
|
||||||
|
[Memberships](memberships.md)). An instance with neither configured
|
||||||
|
grants nothing and says so in the log; the transition itself always
|
||||||
|
stands.
|
||||||
|
|
||||||
### after
|
### after
|
||||||
|
|
||||||
|
|||||||
+32
-6
@@ -51,7 +51,17 @@ action invite appliesTo { principal: [User], resource: [Group] };
|
|||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum Principal {
|
pub enum Principal {
|
||||||
/// A signed-in person, or nobody (`groups` empty, `sub` "anonymous").
|
/// A signed-in person, or nobody (`groups` empty, `sub` "anonymous").
|
||||||
User { sub: String, groups: Vec<String> },
|
/// Over NATS, a question may name the person by phone number or
|
||||||
|
/// address instead of listing their groups, and portal looks their
|
||||||
|
/// memberships up (see `serve`).
|
||||||
|
User {
|
||||||
|
sub: String,
|
||||||
|
groups: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
phone: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
email: Option<String>,
|
||||||
|
},
|
||||||
/// Whoever holds one record's own link and knows the email on it.
|
/// Whoever holds one record's own link and knows the email on it.
|
||||||
Submitter { record: String, email: String },
|
Submitter { record: String, email: String },
|
||||||
/// A machine with a shared token.
|
/// A machine with a shared token.
|
||||||
@@ -60,12 +70,12 @@ pub enum Principal {
|
|||||||
|
|
||||||
impl Principal {
|
impl Principal {
|
||||||
pub fn anonymous() -> Self {
|
pub fn anonymous() -> Self {
|
||||||
Principal::User { sub: "anonymous".into(), groups: Vec::new() }
|
Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
|
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
|
||||||
match user {
|
match user {
|
||||||
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone() },
|
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() },
|
||||||
None => Principal::anonymous(),
|
None => Principal::anonymous(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,7 +321,7 @@ impl Policy {
|
|||||||
};
|
};
|
||||||
let mut entities: Vec<Entity> = Vec::new();
|
let mut entities: Vec<Entity> = Vec::new();
|
||||||
let principal_uid = match principal {
|
let principal_uid = match principal {
|
||||||
Principal::User { sub, groups } => {
|
Principal::User { sub, groups, .. } => {
|
||||||
let parents: HashSet<EntityUid> = groups.iter().map(|g| uid("Group", g)).collect();
|
let parents: HashSet<EntityUid> = groups.iter().map(|g| uid("Group", g)).collect();
|
||||||
for g in &parents {
|
for g in &parents {
|
||||||
entities.push(Entity::new_no_attrs(g.clone(), HashSet::new()));
|
entities.push(Entity::new_no_attrs(g.clone(), HashSet::new()));
|
||||||
@@ -444,6 +454,7 @@ pub async fn serve(
|
|||||||
nats: async_nats::Client,
|
nats: async_nats::Client,
|
||||||
site: String,
|
site: String,
|
||||||
access: std::sync::Arc<arc_swap::ArcSwap<Policy>>,
|
access: std::sync::Arc<arc_swap::ArcSwap<Policy>>,
|
||||||
|
state: Option<crate::server::AppState>,
|
||||||
) {
|
) {
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
let subject = subject_for(&site);
|
let subject = subject_for(&site);
|
||||||
@@ -458,7 +469,22 @@ pub async fn serve(
|
|||||||
while let Some(msg) = sub.next().await {
|
while let Some(msg) = sub.next().await {
|
||||||
let Some(reply) = msg.reply else { continue };
|
let Some(reply) = msg.reply else { continue };
|
||||||
let decision = match serde_json::from_slice::<Query>(&msg.payload) {
|
let decision = match serde_json::from_slice::<Query>(&msg.payload) {
|
||||||
Ok(q) => decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context),
|
Ok(mut q) => {
|
||||||
|
// A question that names a person and lists no groups is
|
||||||
|
// asking portal what they belong to: their memberships,
|
||||||
|
// matched on subject, phone number or address.
|
||||||
|
if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) {
|
||||||
|
if groups.is_empty() && sub != "anonymous" {
|
||||||
|
let who = crate::memberships::Identity {
|
||||||
|
sub: Some(sub.clone()),
|
||||||
|
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
||||||
|
email: email.as_deref().map(|e| e.trim().to_lowercase()),
|
||||||
|
};
|
||||||
|
*groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context)
|
||||||
|
}
|
||||||
Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] },
|
Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] },
|
||||||
};
|
};
|
||||||
let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
|
let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
|
||||||
@@ -487,7 +513,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn user(groups: &[&str]) -> Principal {
|
fn user(groups: &[&str]) -> Principal {
|
||||||
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect() }
|
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+12
-7
@@ -55,11 +55,15 @@ pub struct AggregateSchema {
|
|||||||
|
|
||||||
/// A mail to send when a record enters a state. `to` is `submitter`
|
/// A mail to send when a record enters a state. `to` is `submitter`
|
||||||
/// (the record's `email` field, which the lint then requires the
|
/// (the record's `email` field, which the lint then requires the
|
||||||
/// forms to collect) or one fixed address. Subject and body are
|
/// forms to collect), one fixed address, or a field of the record
|
||||||
/// templates: `{field}` for any field of the record, plus `{site}`
|
/// this one belongs to: `{notice.email}` when the form has a
|
||||||
/// (the instance's public URL), `{chain}` (the record id), `{email}`,
|
/// `type: record` field called `notice`. That is how an answer to a
|
||||||
/// `{bucket}` and `{state}`. The body is markdown; the mailer sends it
|
/// notice reaches whoever posted it. Subject and body are templates:
|
||||||
/// as plain text with an HTML alternative.
|
/// `{field}` for any field of the record, `{notice.field}` for one of
|
||||||
|
/// the record it belongs to, plus `{site}` (the instance's public
|
||||||
|
/// URL), `{chain}` (the record id), `{email}`, `{bucket}` and
|
||||||
|
/// `{state}`. The body is markdown; the mailer sends it as plain text
|
||||||
|
/// with an HTML alternative.
|
||||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct MailSpec {
|
pub struct MailSpec {
|
||||||
@@ -218,9 +222,10 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
|||||||
if mail.subject.trim().is_empty() || mail.body.trim().is_empty() {
|
if mail.subject.trim().is_empty() || mail.body.trim().is_empty() {
|
||||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
|
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
|
||||||
}
|
}
|
||||||
if !mail.to_submitter() && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
|
let through_a_record = crate::mail::parent_of(&mail.to).is_some();
|
||||||
|
if !mail.to_submitter() && !through_a_record && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter` or one address, not {:?}",
|
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter`, one address, or a field of the record this one belongs to like `{{notice.email}}`, not {:?}",
|
||||||
mail.to
|
mail.to
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -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.
|
||||||
|
|||||||
+295
-31
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
|
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
|
||||||
use crate::auth::{current_user, User};
|
use crate::auth::{current_user, User};
|
||||||
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
|
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, RowLink, SiteConfig, Transition};
|
||||||
use crate::i18n::t;
|
use crate::i18n::t;
|
||||||
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
|
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
.map(|_| view! { <SuggestEdit question_id=question.id.clone()/> })}
|
||||||
{question
|
{question
|
||||||
.responsible
|
.responsible
|
||||||
.clone()
|
.clone()
|
||||||
@@ -470,7 +477,7 @@ fn QuestionView(
|
|||||||
/// Every other question the visitor currently qualifies for, as a
|
/// Every other question the visitor currently qualifies for, as a
|
||||||
/// small nav - how a concern reaches the people it speaks to without
|
/// small nav - how a concern reaches the people it speaks to without
|
||||||
/// claiming front-page space (an owner also sees the gated desks here).
|
/// claiming front-page space (an owner also sees the gated desks here).
|
||||||
/// Where the arrow on the left takes a visitor: the nearest page below
|
/// Where the link at the foot of a page takes a visitor: the nearest page below
|
||||||
/// the current one in the tree that they may open, found by taking the
|
/// the current one in the tree that they may open, found by taking the
|
||||||
/// path apart one segment at a time (`/a/b/c`, then `/a/b`, then `/a`),
|
/// path apart one segment at a time (`/a/b/c`, then `/a/b`, then `/a`),
|
||||||
/// and the front door when none of those is a page. `None` on the front
|
/// and the front door when none of those is a page. `None` on the front
|
||||||
@@ -516,24 +523,10 @@ fn QuestionNav(current_id: String) -> impl IntoView {
|
|||||||
let NavRes(nav) = expect_context();
|
let NavRes(nav) = expect_context();
|
||||||
let location = leptos_router::hooks::use_location();
|
let location = leptos_router::hooks::use_location();
|
||||||
view! {
|
view! {
|
||||||
// The way down the tree: fixed at the left edge, half-way up the
|
// The way down the tree, at the foot of the page, in words:
|
||||||
// screen, small enough never to cover what a visitor is reading.
|
// where a reader who has read to the end looks for where to go
|
||||||
// It replaces a "back" alternative on every page that had one.
|
// next. It replaces a "back" alternative on every page that had
|
||||||
<Suspense fallback=|| ()>
|
// one.
|
||||||
{move || {
|
|
||||||
let path = location.pathname.get();
|
|
||||||
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
|
|
||||||
let label = down_the_tree_label(&lang().get(), &href, &name);
|
|
||||||
view! {
|
|
||||||
<a class="down-the-tree" href=href aria-label=label.clone() title=label>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5 8 12l7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</Suspense>
|
|
||||||
// The same way down, at the foot of the page, in words: where a
|
|
||||||
// reader who has read to the end looks for where to go next.
|
|
||||||
<Suspense fallback=|| ()>
|
<Suspense fallback=|| ()>
|
||||||
{move || {
|
{move || {
|
||||||
let path = location.pathname.get();
|
let path = location.pathname.get();
|
||||||
@@ -618,6 +611,103 @@ 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 answers for a page: their Kanidm
|
||||||
|
/// address equals the page's `responsible.contact` (`mailto:` off,
|
||||||
|
/// case ignored), or they are in the group that holds it.
|
||||||
|
pub fn is_responsible(user: Option<&User>, responsible: &Responsible) -> bool {
|
||||||
|
let Some(user) = user else { return false };
|
||||||
|
if responsible.group.as_deref().is_some_and(|g| !g.is_empty() && user.groups.iter().any(|mine| mine == g)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let contact = responsible.contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||||
|
user.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
|
||||||
@@ -1215,6 +1305,7 @@ fn AlternativeCard(
|
|||||||
alternative=alt_name.clone()
|
alternative=alt_name.clone()
|
||||||
feature_name=feature_name.clone()
|
feature_name=feature_name.clone()
|
||||||
transitions=spec.transitions.clone()
|
transitions=spec.transitions.clone()
|
||||||
|
link=spec.link.clone()
|
||||||
empty=spec.empty.clone()
|
empty=spec.empty.clone()
|
||||||
pending_transitions=pending_transitions
|
pending_transitions=pending_transitions
|
||||||
transition_batch=transition_batch
|
transition_batch=transition_batch
|
||||||
@@ -1667,6 +1758,7 @@ fn ResourceFeature(
|
|||||||
alternative: String,
|
alternative: String,
|
||||||
feature_name: String,
|
feature_name: String,
|
||||||
transitions: Vec<Transition>,
|
transitions: Vec<Transition>,
|
||||||
|
link: Option<RowLink>,
|
||||||
empty: Option<String>,
|
empty: Option<String>,
|
||||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||||
transition_batch: ServerAction<TransitionAnswers>,
|
transition_batch: ServerAction<TransitionAnswers>,
|
||||||
@@ -1701,6 +1793,7 @@ fn ResourceFeature(
|
|||||||
{move || {
|
{move || {
|
||||||
let feature_name = feature_name.clone();
|
let feature_name = feature_name.clone();
|
||||||
let transitions = transitions.clone();
|
let transitions = transitions.clone();
|
||||||
|
let link = link.clone();
|
||||||
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
|
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
|
||||||
data.get()
|
data.get()
|
||||||
.map(|res| match res {
|
.map(|res| match res {
|
||||||
@@ -1709,6 +1802,7 @@ fn ResourceFeature(
|
|||||||
<ResourceValue
|
<ResourceValue
|
||||||
feature_name=feature_name
|
feature_name=feature_name
|
||||||
transitions=transitions
|
transitions=transitions
|
||||||
|
link=link
|
||||||
empty=empty
|
empty=empty
|
||||||
value=value
|
value=value
|
||||||
pending_transitions=pending_transitions
|
pending_transitions=pending_transitions
|
||||||
@@ -1736,6 +1830,7 @@ fn ResourceFeature(
|
|||||||
fn ResourceValue(
|
fn ResourceValue(
|
||||||
feature_name: String,
|
feature_name: String,
|
||||||
transitions: Vec<Transition>,
|
transitions: Vec<Transition>,
|
||||||
|
link: Option<RowLink>,
|
||||||
empty: String,
|
empty: String,
|
||||||
value: serde_json::Value,
|
value: serde_json::Value,
|
||||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||||
@@ -1767,11 +1862,13 @@ fn ResourceValue(
|
|||||||
children={
|
children={
|
||||||
let feature_name = feature_name.clone();
|
let feature_name = feature_name.clone();
|
||||||
let transitions = transitions.clone();
|
let transitions = transitions.clone();
|
||||||
|
let link = link.clone();
|
||||||
move |answer: Answer| {
|
move |answer: Answer| {
|
||||||
view! {
|
view! {
|
||||||
<AnswerRow
|
<AnswerRow
|
||||||
feature_name=feature_name.clone()
|
feature_name=feature_name.clone()
|
||||||
transitions=transitions.clone()
|
transitions=transitions.clone()
|
||||||
|
link=link.clone()
|
||||||
answer=answer
|
answer=answer
|
||||||
pending_transitions=pending_transitions
|
pending_transitions=pending_transitions
|
||||||
transition_batch=transition_batch
|
transition_batch=transition_batch
|
||||||
@@ -2030,6 +2127,7 @@ fn SelectField(
|
|||||||
fn AnswerRow(
|
fn AnswerRow(
|
||||||
feature_name: String,
|
feature_name: String,
|
||||||
transitions: Vec<Transition>,
|
transitions: Vec<Transition>,
|
||||||
|
link: Option<RowLink>,
|
||||||
answer: Answer,
|
answer: Answer,
|
||||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||||
transition_batch: ServerAction<TransitionAnswers>,
|
transition_batch: ServerAction<TransitionAnswers>,
|
||||||
@@ -2062,6 +2160,7 @@ fn AnswerRow(
|
|||||||
let state = answer.state.clone();
|
let state = answer.state.clone();
|
||||||
let item_id = answer.id.clone();
|
let item_id = answer.id.clone();
|
||||||
let key = (feature_name.clone(), item_id.clone());
|
let key = (feature_name.clone(), item_id.clone());
|
||||||
|
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<article class="answer-row" class:decided=move || decided>
|
<article class="answer-row" class:decided=move || decided>
|
||||||
@@ -2109,6 +2208,9 @@ fn AnswerRow(
|
|||||||
<span class="answer-state">{state}</span>
|
<span class="answer-state">{state}</span>
|
||||||
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
// The page about this record, when the list offers one:
|
||||||
|
// where a reader answers a notice.
|
||||||
|
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
|
||||||
// What the person has written back since, oldest first: the
|
// What the person has written back since, oldest first: the
|
||||||
// conversation beside the case, which a desk could not see
|
// conversation beside the case, which a desk could not see
|
||||||
// before. A note moves nothing, so there is no button here.
|
// before. A note moves nothing, so there is no button here.
|
||||||
@@ -2252,13 +2354,17 @@ pub async fn get_question(
|
|||||||
path: String,
|
path: String,
|
||||||
chain: Option<String>,
|
chain: Option<String>,
|
||||||
) -> Result<Option<Page>, ServerFnError> {
|
) -> Result<Option<Page>, ServerFnError> {
|
||||||
use crate::content::{path_matches, resolve_question};
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
|
use crate::content::{fill_from_user, path_matches, resolve_question};
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
let questions = state.questions.load();
|
let questions = state.questions.load();
|
||||||
let Some(question) = resolve_question(&questions, &path) else {
|
let Some(mut question) = resolve_question(&questions, &path) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
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()))?;
|
||||||
|
fill_from_user(&mut question, user.as_ref());
|
||||||
let mut chain_gate = None;
|
let mut chain_gate = None;
|
||||||
if let Some(target) = &question.requires_chain {
|
if let Some(target) = &question.requires_chain {
|
||||||
// The claimed lineage must verifiably end at the required
|
// The claimed lineage must verifiably end at the required
|
||||||
@@ -2433,6 +2539,129 @@ 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) {
|
||||||
|
return Err(ServerFnError::new("only whoever is 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who a principal is, for a record: the username, or the service.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn principal_name(principal: &crate::access::Principal) -> String {
|
||||||
|
match principal {
|
||||||
|
crate::access::Principal::User { sub, .. } => sub.clone(),
|
||||||
|
crate::access::Principal::Submitter { email, .. } => email.clone(),
|
||||||
|
crate::access::Principal::Service { name } => name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[server(endpoint = "submit_answer")]
|
#[server(endpoint = "submit_answer")]
|
||||||
pub async fn submit_answer(
|
pub async fn submit_answer(
|
||||||
question_id: String,
|
question_id: String,
|
||||||
@@ -2524,21 +2753,22 @@ pub async fn submit_answer(
|
|||||||
if !may.allow {
|
if !may.allow {
|
||||||
return Err(ServerFnError::new("not authorized to invite into this group"));
|
return Err(ServerFnError::new("not authorized to invite into this group"));
|
||||||
}
|
}
|
||||||
let kanidm = crate::people::Kanidm::from_env()
|
let people = crate::people::People::from_env().ok_or_else(|| ServerFnError::new(crate::people::People::NOT_CONFIGURED))?;
|
||||||
.ok_or_else(|| ServerFnError::new("invites are not configured on this instance (KANIDM_API_TOKEN)"))?;
|
let Some((name, who)) = crate::people::who_for(&responses) else {
|
||||||
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
|
||||||
let (name, email) = match (field("name"), field("email")) {
|
|
||||||
(Some(n), Some(e)) => (n, e),
|
|
||||||
_ => return Err(ServerFnError::new("an invite needs a name and an email")),
|
|
||||||
};
|
};
|
||||||
let invited = kanidm
|
let invited = people
|
||||||
.invite(&name, &email, &invite.group)
|
.invite(&state, &name, &who, &invite.group, &principal_name(&principal))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
|
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
|
||||||
if let Some(map) = responses.as_object_mut() {
|
if let Some(map) = responses.as_object_mut() {
|
||||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||||
}
|
}
|
||||||
record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string());
|
// Through Kanidm the invite is its own case, with a link that
|
||||||
|
// runs out; a membership is the case already.
|
||||||
|
if people.is_kanidm() {
|
||||||
|
record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string());
|
||||||
|
}
|
||||||
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
if let Ok(bytes) = serde_json::to_vec(&invited) {
|
||||||
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await;
|
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await;
|
||||||
}
|
}
|
||||||
@@ -2681,3 +2911,37 @@ 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;
|
||||||
|
use crate::content::Responsible;
|
||||||
|
|
||||||
|
fn user(email: Option<&str>, groups: &[&str]) -> User {
|
||||||
|
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: email.map(String::from) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named(contact: &str, group: Option<&str>) -> Responsible {
|
||||||
|
Responsible { name: "R".into(), contact: contact.into(), group: group.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"), &[])), &named("mailto:Kari@Vel.no", None)));
|
||||||
|
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &[])), &named("kari@vel.no", None)));
|
||||||
|
assert!(!is_responsible(Some(&user(None, &[])), &named("kari@vel.no", None)));
|
||||||
|
assert!(!is_responsible(None, &named("kari@vel.no", None)));
|
||||||
|
assert!(!is_responsible(Some(&user(Some(""), &[])), &named("", None)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_a_group_answers_for_is_every_members() {
|
||||||
|
let board = named("post@vel.no", Some("styret"));
|
||||||
|
assert!(is_responsible(Some(&user(Some("ola@vel.no"), &["styret"])), &board));
|
||||||
|
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &["trafikk"])), &board));
|
||||||
|
// The inbox itself, signed in, still counts.
|
||||||
|
assert!(is_responsible(Some(&user(Some("post@vel.no"), &[])), &board));
|
||||||
|
assert!(!is_responsible(None, &board));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ 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 phone number the provider verified (`phone_number`, from
|
||||||
|
/// userinfo), when it gives one: what a membership is keyed by first.
|
||||||
|
#[serde(default)]
|
||||||
|
pub phone: Option<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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
+92
-1
@@ -207,6 +207,35 @@ impl Question {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The signed-in person's own details into the fields that ask for
|
||||||
|
/// them: `value: "{user.email}"` becomes their address and the field
|
||||||
|
/// hidden, since asking someone who is known to say who they are is
|
||||||
|
/// the form doubting them. Without a session the placeholder is
|
||||||
|
/// emptied, and the field asks. Only the whole of a value is filled
|
||||||
|
/// this way; a value that mixes a user placeholder with other text is
|
||||||
|
/// left alone.
|
||||||
|
pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>) {
|
||||||
|
for alt in &mut question.alternatives {
|
||||||
|
for feature in &mut alt.features {
|
||||||
|
for req in &mut feature.requirements {
|
||||||
|
let Some(value) = req.value.as_deref() else { continue };
|
||||||
|
let filled = match value {
|
||||||
|
"{user.email}" => user.and_then(|u| u.email.clone()),
|
||||||
|
"{user.name}" => user.map(|u| u.display_name.clone()),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
match filled.filter(|v| !v.trim().is_empty()) {
|
||||||
|
Some(v) => {
|
||||||
|
req.value = Some(v);
|
||||||
|
req.kind = "hidden".to_string();
|
||||||
|
}
|
||||||
|
None => req.value = None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An alternative's `action` may carry `{field}` / `{field.sub}`
|
/// An alternative's `action` may carry `{field}` / `{field.sub}`
|
||||||
/// placeholders filled from the submitted responses - `/shape/{curve.key}`
|
/// placeholders filled from the submitted responses - `/shape/{curve.key}`
|
||||||
/// lands on the dynamic page `/shape/[key]` for the key the visitor's
|
/// lands on the dynamic page `/shape/[key]` for the key the visitor's
|
||||||
@@ -267,6 +296,13 @@ pub struct Responsible {
|
|||||||
/// A mailto address or plain email - deliberately just a string,
|
/// A mailto address or plain email - deliberately just a string,
|
||||||
/// same as everywhere else content keeps contact info simple.
|
/// same as everywhere else content keeps contact info simple.
|
||||||
pub contact: String,
|
pub contact: String,
|
||||||
|
/// The group that holds the responsibility, when it is a group's
|
||||||
|
/// and not one person's: the board's inbox, not the board's
|
||||||
|
/// secretary. Then the address gets no account of its own (see
|
||||||
|
/// `seed.rs`), and any signed-in member of the group may suggest a
|
||||||
|
/// change to the page.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub group: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether to render `question`'s alternatives or its sign-in gate for
|
/// Whether to render `question`'s alternatives or its sign-in gate for
|
||||||
@@ -439,6 +475,14 @@ pub struct ResourceSpec {
|
|||||||
/// didn't declare. `Kv` sources only.
|
/// didn't declare. `Kv` sources only.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub transitions: Vec<Transition>,
|
pub transitions: Vec<Transition>,
|
||||||
|
/// A way from each listed record to a page about it: `to` is the
|
||||||
|
/// page, with `{id}` for the record's id and `{field}` for any of
|
||||||
|
/// its fields, and `label` the words on the link. The other half
|
||||||
|
/// of `type: record`, seen from the list: a notice on a board links
|
||||||
|
/// to the page where a neighbour answers it, and that page's form
|
||||||
|
/// carries the notice's id back in.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub link: Option<RowLink>,
|
||||||
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
|
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
|
||||||
/// fetched value before it reaches the frontend - e.g.
|
/// fetched value before it reaches the frontend - e.g.
|
||||||
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
|
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
|
||||||
@@ -542,7 +586,10 @@ pub struct Requirement {
|
|||||||
/// A preset value the field starts with. On a dynamic page the
|
/// A preset value the field starts with. On a dynamic page the
|
||||||
/// URL segment substitutes into `{name}` placeholders here, like
|
/// URL segment substitutes into `{name}` placeholders here, like
|
||||||
/// resource keys - `value: "{key}"` on a hidden field carries the
|
/// resource keys - `value: "{key}"` on a hidden field carries the
|
||||||
/// page's key into the answer.
|
/// page's key into the answer. `{user.email}` and `{user.name}`
|
||||||
|
/// are the signed-in person's, and a field they fill is carried
|
||||||
|
/// rather than asked (see `fill_from_user`); for a visitor they
|
||||||
|
/// are empty, and the field asks as usual.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub value: Option<String>,
|
pub value: Option<String>,
|
||||||
/// `type: record` only - the bucket the referenced record lives in.
|
/// `type: record` only - the bucket the referenced record lives in.
|
||||||
@@ -574,6 +621,30 @@ pub struct Requirement {
|
|||||||
/// listing may ask about a record's own contents: one field, one
|
/// listing may ask about a record's own contents: one field, one
|
||||||
/// value, compared as strings. Anything more is a query language, and
|
/// value, compared as strings. Anything more is a query language, and
|
||||||
/// a jq filter is already there for shaping what comes back.
|
/// a jq filter is already there for shaping what comes back.
|
||||||
|
/// A link from every listed record to a page about it (`ResourceSpec.link`).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct RowLink {
|
||||||
|
pub to: String,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RowLink {
|
||||||
|
/// The link for one record: `{id}` is the record's id, `{name}`
|
||||||
|
/// any string field of its responses. A placeholder nothing fills
|
||||||
|
/// stays as it is, which is visible, rather than a wrong page.
|
||||||
|
pub fn href(&self, id: &str, responses: &serde_json::Value) -> String {
|
||||||
|
let mut out = self.to.replace("{id}", id);
|
||||||
|
if let Some(fields) = responses.as_object() {
|
||||||
|
for (k, v) in fields {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
out = out.replace(&format!("{{{k}}}"), s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct FieldIs {
|
pub struct FieldIs {
|
||||||
pub field: String,
|
pub field: String,
|
||||||
@@ -1790,6 +1861,26 @@ alternatives:
|
|||||||
assert_eq!(render_inline_markdown("a\nb"), "a b");
|
assert_eq!(render_inline_markdown("a\nb"), "a b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_signed_in_person_is_not_asked_who_they_are() {
|
||||||
|
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
|
||||||
|
let q: Question = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
|
||||||
|
let mut signed_in = q.clone();
|
||||||
|
fill_from_user(&mut signed_in, Some(&user));
|
||||||
|
let reqs = &signed_in.alternatives[0].features[0].requirements;
|
||||||
|
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no")));
|
||||||
|
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
|
||||||
|
// Mixed text is not a user field, and is left as written.
|
||||||
|
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
|
||||||
|
// A visitor is asked: the placeholder goes, the field stays.
|
||||||
|
let mut visitor = q.clone();
|
||||||
|
fill_from_user(&mut visitor, None);
|
||||||
|
let reqs = &visitor.alternatives[0].features[0].requirements;
|
||||||
|
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
|
||||||
|
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn markdown_drops_html_and_unsafe_links() {
|
fn markdown_drops_html_and_unsafe_links() {
|
||||||
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
|
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
|
||||||
|
|||||||
+52
-1
@@ -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,33 @@ 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_memberships
|
||||||
|
attended_by: the desks that list who is in a group; portal itself moves invited to active at sign-in
|
||||||
|
initial: invited
|
||||||
|
states:
|
||||||
|
invited: { event: invited }
|
||||||
|
active: { event: active }
|
||||||
|
removed: { event: removed }
|
||||||
|
transitions:
|
||||||
|
invited: [active, removed]
|
||||||
|
active: [removed]
|
||||||
|
removed: [invited, active]
|
||||||
|
- 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
|
||||||
@@ -116,6 +146,27 @@ pub fn validate_questions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A mail to the record this one belongs to goes through a field of
|
||||||
|
// the form: `{notice.email}` needs a `type: record` field called
|
||||||
|
// `notice` on every form that records into the bucket, or the mail
|
||||||
|
// has nowhere to go.
|
||||||
|
for (bucket, schema) in aggregates {
|
||||||
|
for (state, spec) in &schema.mail_for_state {
|
||||||
|
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
|
||||||
|
for question in questions.values() {
|
||||||
|
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||||
|
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
|
||||||
|
if !has {
|
||||||
|
anyhow::bail!(
|
||||||
|
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
|
||||||
|
question.id, alternative.name, spec.to
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A state that grants a group makes an account for the person the
|
// A state that grants a group makes an account for the person the
|
||||||
// record is about, so the record has to know who they are. Same
|
// record is about, so the record has to know who they are. Same
|
||||||
// shape as an invite's own rule, one step further back: the form
|
// shape as an invite's own rule, one step further back: the form
|
||||||
|
|||||||
+27
@@ -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}");
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ pub mod people;
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod mail;
|
pub mod mail;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod seed;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod memberships;
|
||||||
|
|
||||||
pub mod maps;
|
pub mod maps;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|||||||
+286
-4
@@ -251,6 +251,49 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
|
|||||||
vars
|
vars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The record field a `to` reaches through, when it is neither
|
||||||
|
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
||||||
|
pub fn parent_of(to: &str) -> Option<&str> {
|
||||||
|
let inner = to.trim().strip_prefix('{')?.strip_suffix('}')?;
|
||||||
|
let (field, _) = inner.split_once('.')?;
|
||||||
|
(!field.is_empty() && !inner.contains(char::is_whitespace)).then_some(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields of every record this one belongs to, as `{field.name}`:
|
||||||
|
/// for each `type: record` field of the forms that record into
|
||||||
|
/// `bucket`, the record it names, read from its own bucket. A
|
||||||
|
/// reference that does not resolve adds nothing, and a template that
|
||||||
|
/// needed it renders empty there.
|
||||||
|
async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map<String, serde_json::Value>) {
|
||||||
|
let questions = state.questions.load();
|
||||||
|
let refs: Vec<(String, String)> = questions
|
||||||
|
.values()
|
||||||
|
.flat_map(|q| q.alternatives.iter())
|
||||||
|
.filter(|a| a.record_as.as_deref() == Some(bucket))
|
||||||
|
.flat_map(|a| a.features.iter().flat_map(|f| f.requirements.iter()))
|
||||||
|
.filter(|r| r.kind == "record")
|
||||||
|
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
|
||||||
|
.collect();
|
||||||
|
for (field, of) in refs {
|
||||||
|
let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue };
|
||||||
|
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
|
||||||
|
let Ok(Some(bytes)) = store.get(id).await else { continue };
|
||||||
|
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
|
||||||
|
if let Some(fields) = parent.responses.as_object() {
|
||||||
|
for (k, v) in fields {
|
||||||
|
let text = match v {
|
||||||
|
serde_json::Value::String(s) => s.clone(),
|
||||||
|
serde_json::Value::Null => String::new(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
vars.insert(format!("{field}.{k}"), text.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vars.insert(format!("{field}.id"), parent.id.into());
|
||||||
|
vars.insert(format!("{field}.state"), parent.state.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
|
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
|
||||||
let mut headers = async_nats::HeaderMap::new();
|
let mut headers = async_nats::HeaderMap::new();
|
||||||
headers.insert("Nats-Msg-Id", mail.id.as_str());
|
headers.insert("Nats-Msg-Id", mail.id.as_str());
|
||||||
@@ -265,6 +308,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();
|
||||||
@@ -272,7 +320,8 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
|||||||
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
|
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let vars = vars_for(bucket, id, to_state, responses);
|
let mut vars = vars_for(bucket, id, to_state, responses);
|
||||||
|
parent_vars(state, bucket, responses, &mut vars).await;
|
||||||
let to = if spec.to_submitter() {
|
let to = if spec.to_submitter() {
|
||||||
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
||||||
Some(e) => e.to_string(),
|
Some(e) => e.to_string(),
|
||||||
@@ -282,7 +331,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
spec.to.clone()
|
// A field of the record this one belongs to, or an address.
|
||||||
|
let to = render(&spec.to, &vars);
|
||||||
|
if !to.contains('@') {
|
||||||
|
tracing::warn!(bucket, id, to_state, to = %spec.to, "the mail's address did not resolve to one - not sent");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
to
|
||||||
};
|
};
|
||||||
let text = render(&spec.body, &vars);
|
let text = render(&spec.body, &vars);
|
||||||
let mail = Outgoing {
|
let mail = Outgoing {
|
||||||
@@ -301,6 +356,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}`,
|
||||||
@@ -320,7 +565,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
|||||||
),
|
),
|
||||||
(None, false, true) => (
|
(None, false, true) => (
|
||||||
"Du har fått plass i {group} på {site_name}".to_string(),
|
"Du har fått plass i {group} på {site_name}".to_string(),
|
||||||
"Hei {name},\n\nKontoen din på {site} har nå plass i {group}. Logg inn som vanlig.".to_string(),
|
"Hei {name},\n\nDu har fått plass i {group} på {site_name}. Logg inn på {site}, så er plassen din der.".to_string(),
|
||||||
),
|
),
|
||||||
(None, true, false) => (
|
(None, true, false) => (
|
||||||
"You are invited into {group} at {site_name}".to_string(),
|
"You are invited into {group} at {site_name}".to_string(),
|
||||||
@@ -328,7 +573,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
|||||||
),
|
),
|
||||||
(None, false, false) => (
|
(None, false, false) => (
|
||||||
"You now have a place in {group} at {site_name}".to_string(),
|
"You now have a place in {group} at {site_name}".to_string(),
|
||||||
"Hello {name},\n\nYour account at {site} now has a place in {group}. Sign in as usual.".to_string(),
|
"Hello {name},\n\nYou now have a place in {group} at {site_name}. Sign in at {site}, and it is there.".to_string(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
@@ -367,6 +612,43 @@ 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]
|
||||||
|
fn a_mail_reaches_through_a_record_field_and_nothing_else() {
|
||||||
|
assert_eq!(parent_of("{notice.email}"), Some("notice"));
|
||||||
|
assert_eq!(parent_of(" {case.contact} "), Some("case"));
|
||||||
|
assert_eq!(parent_of("submitter"), None);
|
||||||
|
assert_eq!(parent_of("post@vel.no"), None);
|
||||||
|
assert_eq!(parent_of("{email}"), None);
|
||||||
|
assert_eq!(parent_of("{.email}"), None);
|
||||||
|
assert_eq!(parent_of("{not a.field}"), 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 });
|
||||||
|
|||||||
+9
-3
@@ -86,9 +86,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
site.clone(),
|
site.clone(),
|
||||||
access.clone(),
|
access.clone(),
|
||||||
));
|
));
|
||||||
// The same decisions, answerable by other apps over NATS.
|
|
||||||
tokio::spawn(portal::access::serve(nats.clone(), site_name_for_subjects(), access.clone()));
|
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
leptos_options: leptos_options.clone(),
|
leptos_options: leptos_options.clone(),
|
||||||
nats,
|
nats,
|
||||||
@@ -104,10 +101,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
garage,
|
garage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The same decisions, answerable by other apps over NATS, for a
|
||||||
|
// person named by phone number or address as well as by groups.
|
||||||
|
tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone())));
|
||||||
// Announced pages: keep their desk records current, close ended
|
// Announced pages: keep their desk records current, close ended
|
||||||
// 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
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
//! Who belongs to which group, kept by portal itself.
|
||||||
|
//!
|
||||||
|
//! A membership is a case: one record per (group, person) in the
|
||||||
|
//! built-in `portal_memberships` bucket, `invited` until the person is
|
||||||
|
//! seen, `active` from then on, `removed` when a desk takes them out.
|
||||||
|
//! A desk invite writes one, a state that `grants` a group writes one,
|
||||||
|
//! the first person and the people the content names as responsible
|
||||||
|
//! get theirs at start. Nothing here talks to an identity provider:
|
||||||
|
//! the provider says who someone is, and this says what they belong
|
||||||
|
//! to. At sign-in, the person's active memberships become their groups,
|
||||||
|
//! and the access policy (Cedar, `access.rs`) sees them as the parents
|
||||||
|
//! of the `User` entity, exactly as it saw a `groups` claim before.
|
||||||
|
//!
|
||||||
|
//! **A person is their phone number first, their email second.** A
|
||||||
|
//! provider that verifies phone numbers (Vipps) makes the number the
|
||||||
|
//! surest thing a record can hold; a form that collected an address
|
||||||
|
//! still matches on it. A record's key is the group and whichever of
|
||||||
|
//! the two it has, so a second invite of the same person into the same
|
||||||
|
//! group finds the first.
|
||||||
|
#![cfg(feature = "ssr")]
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::answers::Answer;
|
||||||
|
|
||||||
|
pub const BUCKET: &str = "portal_memberships";
|
||||||
|
pub const INVITED: &str = "invited";
|
||||||
|
pub const ACTIVE: &str = "active";
|
||||||
|
pub const REMOVED: &str = "removed";
|
||||||
|
/// Stamped on moves nobody at a desk made.
|
||||||
|
pub const BY: &str = "portal";
|
||||||
|
|
||||||
|
/// The country a phone number without one is in, as a calling code.
|
||||||
|
fn default_country() -> String {
|
||||||
|
std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who a person is, as far as a record or a session knows.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Identity {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sub: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A phone number as one string for everyone: digits with a leading
|
||||||
|
/// `+` and a country code, `+4791234567`. Spaces, dots and dashes go;
|
||||||
|
/// `00` becomes `+`; a bare national number gets the site's country.
|
||||||
|
/// `None` for anything that is not a number.
|
||||||
|
pub fn normalize_phone(raw: &str, country: &str) -> Option<String> {
|
||||||
|
let mut digits: String = raw.trim().chars().filter(|c| c.is_ascii_digit()).collect();
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if digits.is_empty() || digits.len() < 5 || digits.len() > 15 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with('+') {
|
||||||
|
return Some(format!("+{digits}"));
|
||||||
|
}
|
||||||
|
if let Some(rest) = digits.strip_prefix("00") {
|
||||||
|
return Some(format!("+{rest}"));
|
||||||
|
}
|
||||||
|
if trimmed.starts_with('(') && !trimmed.contains('+') {
|
||||||
|
digits = digits.trim_start_matches('0').to_string();
|
||||||
|
}
|
||||||
|
Some(format!("{}{digits}", country.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_email(raw: &str) -> Option<String> {
|
||||||
|
let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase();
|
||||||
|
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Identity {
|
||||||
|
/// From what a form collected: the first phone-like field and the
|
||||||
|
/// first email-like field, by the names forms use for them.
|
||||||
|
pub fn from_responses(responses: &serde_json::Value) -> Self {
|
||||||
|
let field = |names: &[&str]| {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.find_map(|n| responses.get(*n).and_then(|v| v.as_str()))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from)
|
||||||
|
};
|
||||||
|
let country = default_country();
|
||||||
|
Identity {
|
||||||
|
sub: None,
|
||||||
|
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)),
|
||||||
|
email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_user(user: &crate::auth::User) -> Self {
|
||||||
|
let country = default_country();
|
||||||
|
Identity {
|
||||||
|
sub: Some(user.sub.clone()),
|
||||||
|
phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)),
|
||||||
|
email: user.email.as_deref().and_then(normalize_email),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One person given as one string: a phone number if it reads as
|
||||||
|
/// one, else an address.
|
||||||
|
pub fn from_contact(contact: &str) -> Self {
|
||||||
|
let country = default_country();
|
||||||
|
match normalize_email(contact) {
|
||||||
|
Some(email) => Identity { sub: None, phone: None, email: Some(email) },
|
||||||
|
None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a membership record is keyed by: the phone number, else the
|
||||||
|
/// address, else the provider's subject. `None` when there is
|
||||||
|
/// nothing to know the person by.
|
||||||
|
pub fn key(&self) -> Option<String> {
|
||||||
|
self.phone
|
||||||
|
.as_deref()
|
||||||
|
.or(self.email.as_deref())
|
||||||
|
.or(self.sub.as_deref())
|
||||||
|
.map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a record about `other` is about this person: the same
|
||||||
|
/// subject, phone number or address, any one of them.
|
||||||
|
pub fn matches(&self, other: &Identity) -> bool {
|
||||||
|
let same = |a: &Option<String>, b: &Option<String>| a.is_some() && a == b;
|
||||||
|
same(&self.sub, &other.sub) || same(&self.phone, &other.phone) || same(&self.email, &other.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is worth writing on a record from a login: everything the
|
||||||
|
/// provider verified that the record did not have.
|
||||||
|
fn merged_from(&self, seen: &Identity) -> Identity {
|
||||||
|
Identity {
|
||||||
|
sub: self.sub.clone().or_else(|| seen.sub.clone()),
|
||||||
|
phone: self.phone.clone().or_else(|| seen.phone.clone()),
|
||||||
|
email: self.email.clone().or_else(|| seen.email.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One record's id: the group and the person's key.
|
||||||
|
pub fn record_id(group: &str, key: &str) -> String {
|
||||||
|
format!("{}.{key}", group.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity a record holds.
|
||||||
|
pub fn identity_of(answer: &Answer) -> Identity {
|
||||||
|
Identity {
|
||||||
|
sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||||
|
phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||||
|
email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group_of(answer: &Answer) -> String {
|
||||||
|
answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The groups `who` is an active member of, from every record.
|
||||||
|
pub fn groups_of(records: &[Answer], who: &Identity) -> Vec<String> {
|
||||||
|
let mut groups: Vec<String> = records
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.state == ACTIVE && identity_of(a).matches(who))
|
||||||
|
.map(group_of)
|
||||||
|
.filter(|g| !g.is_empty())
|
||||||
|
.collect();
|
||||||
|
groups.sort();
|
||||||
|
groups.dedup();
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store(state: &crate::server::AppState) -> anyhow::Result<async_nats::jetstream::kv::Store> {
|
||||||
|
match state.jetstream.get_key_value(BUCKET).await {
|
||||||
|
Ok(store) => Ok(store),
|
||||||
|
Err(_) => Ok(state
|
||||||
|
.jetstream
|
||||||
|
.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() })
|
||||||
|
.await?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every membership record.
|
||||||
|
pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
|
||||||
|
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { return 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 out = Vec::new();
|
||||||
|
for key in keys {
|
||||||
|
if let Ok(Some(bytes)) = store.get(&key).await {
|
||||||
|
if let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) {
|
||||||
|
out.push(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves one record the way a desk button would: through the event
|
||||||
|
/// log, onto the projection, out on the answers subject.
|
||||||
|
async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: Answer, target: &str, by: &str) -> anyhow::Result<Answer> {
|
||||||
|
let aggregates = state.aggregates.load();
|
||||||
|
let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by });
|
||||||
|
crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
answer.state = target.to_string();
|
||||||
|
answer.decided_ms = Some(now_ms);
|
||||||
|
answer.decided_by = Some(by.to_string());
|
||||||
|
store.put(&answer.id, serde_json::to_vec(&answer)?.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,
|
||||||
|
};
|
||||||
|
let _ = crate::events::emit_answer_submitted(&state.nats, &event).await;
|
||||||
|
Ok(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a person in a group, or finds them there. A new record starts
|
||||||
|
/// `invited`; a removed one is invited again; an invited or active one
|
||||||
|
/// is left as it is, so a second grant costs nothing. `created` in
|
||||||
|
/// what comes back says whether the person had a place already.
|
||||||
|
pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who: &Identity, by: &str) -> anyhow::Result<crate::people::Invited> {
|
||||||
|
let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?;
|
||||||
|
let store = store(state).await?;
|
||||||
|
// The person's record in this group, however it was keyed: a desk
|
||||||
|
// that invited by address and a decision that granted by phone
|
||||||
|
// number are talking about one person once a sign-in has joined
|
||||||
|
// the two, and must not make two.
|
||||||
|
let existing: Option<Answer> = all(state).await.into_iter().find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
|
||||||
|
let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key));
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
let invited = |created: bool| crate::people::Invited {
|
||||||
|
username: key.clone(),
|
||||||
|
display_name: name.to_string(),
|
||||||
|
email: who.email.clone().unwrap_or_default(),
|
||||||
|
group: group.to_string(),
|
||||||
|
reset_link: None,
|
||||||
|
link_expires_ms: None,
|
||||||
|
created,
|
||||||
|
already_member: !created,
|
||||||
|
};
|
||||||
|
match existing {
|
||||||
|
Some(answer) if answer.state == REMOVED => {
|
||||||
|
move_to(state, &store, answer, INVITED, by).await?;
|
||||||
|
Ok(invited(true))
|
||||||
|
}
|
||||||
|
Some(_) => Ok(invited(false)),
|
||||||
|
None => {
|
||||||
|
let responses = serde_json::json!({
|
||||||
|
"name": name, "group": group, "by": by,
|
||||||
|
"phone": who.phone, "email": who.email, "sub": who.sub,
|
||||||
|
});
|
||||||
|
crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms).await?;
|
||||||
|
Ok(invited(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes a person out of a group: the record moves to `removed` and
|
||||||
|
/// stays, so the desk can see who was in and put them back.
|
||||||
|
pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity, by: &str) -> anyhow::Result<bool> {
|
||||||
|
let Some(key) = who.key() else { return Ok(false) };
|
||||||
|
let store = store(state).await?;
|
||||||
|
let id = record_id(group, &key);
|
||||||
|
let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::<Answer>(&b).ok()) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if answer.state == REMOVED {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
move_to(state, &store, answer, REMOVED, by).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Someone signed in: every invitation that was for them is now a
|
||||||
|
/// membership, with what the provider verified written on the record,
|
||||||
|
/// and their active groups come back to be their groups for the
|
||||||
|
/// session. Their `sub` is remembered, so the next login matches on
|
||||||
|
/// it before anything else.
|
||||||
|
pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec<String> {
|
||||||
|
let who = Identity::from_user(user);
|
||||||
|
let records = all(state).await;
|
||||||
|
let Ok(store) = store(state).await else { return groups_of(&records, &who) };
|
||||||
|
let mut settled = Vec::with_capacity(records.len());
|
||||||
|
for answer in records {
|
||||||
|
let held = identity_of(&answer);
|
||||||
|
if !held.matches(&who) {
|
||||||
|
settled.push(answer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let merged = held.merged_from(&who);
|
||||||
|
let mut answer = answer;
|
||||||
|
if merged != held {
|
||||||
|
if let Some(map) = answer.responses.as_object_mut() {
|
||||||
|
map.insert("sub".into(), serde_json::json!(merged.sub));
|
||||||
|
map.insert("phone".into(), serde_json::json!(merged.phone));
|
||||||
|
map.insert("email".into(), serde_json::json!(merged.email));
|
||||||
|
}
|
||||||
|
if let Ok(bytes) = serde_json::to_vec(&answer) {
|
||||||
|
let _ = store.put(&answer.id, bytes.into()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if answer.state == INVITED {
|
||||||
|
match move_to(state, &store, answer.clone(), ACTIVE, BY).await {
|
||||||
|
Ok(active) => {
|
||||||
|
tracing::info!(group = %group_of(&active), user = %user.username, "an invitation became a membership");
|
||||||
|
settled.push(active);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(id = %answer.id, "an invitation could not become a membership: {e}");
|
||||||
|
settled.push(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
settled.push(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups_of(&settled, &who)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer {
|
||||||
|
Answer {
|
||||||
|
id: record_id(group, "k"),
|
||||||
|
question_id: "/".into(),
|
||||||
|
alternative: "Membership".into(),
|
||||||
|
responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }),
|
||||||
|
state: state.into(),
|
||||||
|
submitted_ms: 0,
|
||||||
|
decided_ms: None,
|
||||||
|
decided_by: None,
|
||||||
|
notes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_phone_number_is_one_string_for_everyone() {
|
||||||
|
assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into()));
|
||||||
|
assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into()));
|
||||||
|
assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into()));
|
||||||
|
assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".into()));
|
||||||
|
assert_eq!(normalize_phone("kari@vel.no", "+47"), None);
|
||||||
|
assert_eq!(normalize_phone("123", "+47"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_person_is_their_phone_first_and_their_address_second() {
|
||||||
|
let both = Identity::from_responses(&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }));
|
||||||
|
assert_eq!(both.key().as_deref(), Some("_4791234567"));
|
||||||
|
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
|
||||||
|
let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" }));
|
||||||
|
assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no"));
|
||||||
|
assert_eq!(Identity::from_contact("mailto:Post@Vel.no").email.as_deref(), Some("post@vel.no"));
|
||||||
|
assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567"));
|
||||||
|
assert!(Identity::default().key().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() {
|
||||||
|
let kari = Identity { sub: Some("abc".into()), phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
||||||
|
assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari));
|
||||||
|
assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari));
|
||||||
|
assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari));
|
||||||
|
assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari));
|
||||||
|
assert!(!Identity::default().matches(&kari));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_active_memberships_are_groups() {
|
||||||
|
let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
|
||||||
|
let records = vec![
|
||||||
|
record("styret", ACTIVE, Some("+4791234567"), None, None),
|
||||||
|
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
|
||||||
|
record("trafikk", REMOVED, Some("+4791234567"), None, None),
|
||||||
|
record("medlemmer", ACTIVE, None, Some("kari@vel.no"), None),
|
||||||
|
record("styret", ACTIVE, Some("+4799999999"), None, None),
|
||||||
|
];
|
||||||
|
assert_eq!(groups_of(&records, &kari), vec!["medlemmer", "styret"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+524
-51
@@ -8,14 +8,108 @@
|
|||||||
//! 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";
|
||||||
|
|
||||||
|
/// Where people are kept. Kanidm makes accounts and holds groups; with
|
||||||
|
/// memberships (`memberships.rs`) portal holds the groups itself and
|
||||||
|
/// the identity provider only says who someone is. `PEOPLE_BACKEND`
|
||||||
|
/// picks; unset, a `KANIDM_API_TOKEN` means Kanidm and its absence
|
||||||
|
/// means memberships, so a site with no Kanidm token can still invite.
|
||||||
|
pub enum People {
|
||||||
|
Kanidm(Kanidm),
|
||||||
|
Memberships,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl People {
|
||||||
|
/// `None` only when Kanidm is asked for and not configured.
|
||||||
|
pub fn from_env() -> Option<Self> {
|
||||||
|
match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() {
|
||||||
|
Some("memberships") => Some(People::Memberships),
|
||||||
|
Some("kanidm") => Kanidm::from_env().map(People::Kanidm),
|
||||||
|
Some(other) => {
|
||||||
|
tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships");
|
||||||
|
Some(People::Memberships)
|
||||||
|
}
|
||||||
|
None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is missing when `from_env` says no.
|
||||||
|
pub const NOT_CONFIGURED: &'static str = "invites are not configured on this instance (PEOPLE_BACKEND=kanidm needs KANIDM_URL and KANIDM_API_TOKEN)";
|
||||||
|
|
||||||
|
/// Puts a person in a group, making them known if they were not:
|
||||||
|
/// through Kanidm an account and a one-time link, through
|
||||||
|
/// memberships a record that becomes theirs when they sign in.
|
||||||
|
/// `by` is who asked, for the record.
|
||||||
|
pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result<Invited> {
|
||||||
|
match self {
|
||||||
|
People::Kanidm(kanidm) => {
|
||||||
|
let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?;
|
||||||
|
kanidm.invite(name, email, group).await
|
||||||
|
}
|
||||||
|
People::Memberships => {
|
||||||
|
let mut invited = crate::memberships::grant(state, group, name, who, by).await?;
|
||||||
|
// A Kanidm beside memberships makes the person, not the
|
||||||
|
// member: an account with no group, and the one-time
|
||||||
|
// link, so someone invited by address can sign in
|
||||||
|
// before the provider is one everyone already has.
|
||||||
|
if let (Some(kanidm), Some(email)) = (Kanidm::from_env(), who.email.as_deref()) {
|
||||||
|
match kanidm.account(name, email).await {
|
||||||
|
Ok(account) => {
|
||||||
|
invited.username = account.username;
|
||||||
|
invited.reset_link = account.reset_link;
|
||||||
|
invited.link_expires_ms = account.link_expires_ms;
|
||||||
|
invited.created = account.created;
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(invited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes a person out of a group. Through Kanidm only what portal
|
||||||
|
/// put there (see `Invited.already_member`); memberships remember.
|
||||||
|
pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> {
|
||||||
|
match self {
|
||||||
|
People::Kanidm(kanidm) => kanidm.remove_member(group, username).await,
|
||||||
|
People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_kanidm(&self) -> bool {
|
||||||
|
matches!(self, People::Kanidm(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
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 +123,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 +226,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 +245,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 +356,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 {
|
||||||
@@ -189,21 +588,21 @@ pub fn username_for(email: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The person a record is about, for a state that grants a group.
|
/// The person a record is about, for a state that grants a group: their
|
||||||
/// Both are required by the lint of any form that fills such a bucket
|
/// name, and a phone number or an address to know them by. Required by
|
||||||
/// (see `validate_questions`), so a record missing either is one that
|
/// the lint of any form that fills such a bucket (see
|
||||||
/// predates the rule, and is reported rather than guessed at.
|
/// `validate_questions`), so a record missing them is one that predates
|
||||||
pub fn who_for(responses: &serde_json::Value) -> Option<(String, String)> {
|
/// the rule, and is reported rather than guessed at.
|
||||||
let field = |name: &str| {
|
pub fn who_for(responses: &serde_json::Value) -> Option<(String, crate::memberships::Identity)> {
|
||||||
responses
|
let name = responses
|
||||||
.get(name)
|
.get("name")
|
||||||
.and_then(|v| v.as_str())
|
.or_else(|| responses.get("navn"))
|
||||||
.map(str::trim)
|
.and_then(|v| v.as_str())
|
||||||
.filter(|s| !s.is_empty())
|
.map(str::trim)
|
||||||
.map(String::from)
|
.filter(|s| !s.is_empty())
|
||||||
};
|
.map(String::from)?;
|
||||||
let email = field("email").filter(|e| e.contains('@'))?;
|
let who = crate::memberships::Identity::from_responses(responses);
|
||||||
Some((field("name")?, email))
|
who.key().is_some().then_some((name, who))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A record entered `state`: if that state grants a group, put the
|
/// A record entered `state`: if that state grants a group, put the
|
||||||
@@ -229,15 +628,15 @@ pub async fn on_state(
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(kanidm) = Kanidm::from_env() else {
|
let Some(people) = People::from_env() else {
|
||||||
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but invites are not configured on this instance (KANIDM_API_TOKEN) - nobody was added");
|
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some((name, email)) = who_for(responses) else {
|
let Some((name, who)) = who_for(responses) else {
|
||||||
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and email - nobody was added");
|
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let invited = match kanidm.invite(&name, &email, &group).await {
|
let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await {
|
||||||
Ok(invited) => invited,
|
Ok(invited) => invited,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}");
|
tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}");
|
||||||
@@ -271,6 +670,80 @@ 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![],
|
||||||
|
phone: None,
|
||||||
|
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));
|
||||||
|
|||||||
+453
@@ -0,0 +1,453 @@
|
|||||||
|
//! 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 - unless `group` says it is a group's, in which case the
|
||||||
|
//! address is an inbox and nobody is made for it. A person gets 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. A page a group answers
|
||||||
|
/// for (`responsible.group`) names nobody: the board's inbox is not a
|
||||||
|
/// person, and its members have their accounts through the desk.
|
||||||
|
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 };
|
||||||
|
if r.group.as_deref().is_some_and(|g| !g.trim().is_empty()) {
|
||||||
|
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(people_backend) = crate::people::People::from_env() else {
|
||||||
|
if !people.is_empty() {
|
||||||
|
tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED);
|
||||||
|
}
|
||||||
|
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() };
|
||||||
|
let who = crate::memberships::Identity::from_contact(email);
|
||||||
|
// 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 (Kanidm; a membership is nothing without a group). 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, &people_backend) {
|
||||||
|
(Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await,
|
||||||
|
(None, crate::people::People::Kanidm(kanidm)) => 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),
|
||||||
|
},
|
||||||
|
(None, crate::people::People::Memberships) => continue,
|
||||||
|
};
|
||||||
|
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 {
|
||||||
|
let who = crate::memberships::Identity::from_contact(&invited.email);
|
||||||
|
if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").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 -
|
||||||
|
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
|
||||||
|
/// their number.
|
||||||
|
async fn seed_admin(state: &crate::server::AppState) {
|
||||||
|
let phone = std::env::var("SEED_ADMIN_PHONE").ok().map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
|
||||||
|
let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@'));
|
||||||
|
let (Some(contact), who) = (
|
||||||
|
phone.clone().or_else(|| email.clone()),
|
||||||
|
crate::memberships::Identity {
|
||||||
|
sub: None,
|
||||||
|
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
|
||||||
|
email: email.as_deref().map(|e| e.to_lowercase()),
|
||||||
|
},
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let email = contact;
|
||||||
|
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(people) = crate::people::People::from_env() else {
|
||||||
|
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", crate::people::People::NOT_CONFIGURED);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut attempt = 0;
|
||||||
|
let invited = loop {
|
||||||
|
attempt += 1;
|
||||||
|
match people.invite(state, &name, &who, &group, "seed").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);
|
||||||
|
// A group's inbox is nobody: its members have the desk.
|
||||||
|
let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap();
|
||||||
|
held.id = "/d".into();
|
||||||
|
qs.insert("/d".into(), held);
|
||||||
|
assert!(!responsible_people(&qs).contains_key("post@vel.no"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-6
@@ -11,10 +11,10 @@ use axum::{
|
|||||||
response::Redirect,
|
response::Redirect,
|
||||||
};
|
};
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
|
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
|
||||||
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
||||||
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||||
TokenResponse,
|
OAuth2TokenResponse, TokenResponse,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
@@ -50,6 +50,12 @@ const NONCE_KEY: &str = "oidc_nonce";
|
|||||||
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
|
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
|
||||||
|
|
||||||
impl Oidc {
|
impl Oidc {
|
||||||
|
/// Sign-in disabled: what a content-only instance has, and what a
|
||||||
|
/// test builds an `AppState` with.
|
||||||
|
pub fn disabled() -> Self {
|
||||||
|
Self { inner: None }
|
||||||
|
}
|
||||||
|
|
||||||
/// Discovers the provider and builds the client from environment:
|
/// Discovers the provider and builds the client from environment:
|
||||||
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
|
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
|
||||||
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
|
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
|
||||||
@@ -143,7 +149,7 @@ pub async fn login(
|
|||||||
let oidc = state.oidc.configured()?;
|
let oidc = state.oidc.configured()?;
|
||||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
|
||||||
let (auth_url, csrf_state, nonce) = oidc
|
let mut request = oidc
|
||||||
.client
|
.client
|
||||||
.authorize_url(
|
.authorize_url(
|
||||||
CoreAuthenticationFlow::AuthorizationCode,
|
CoreAuthenticationFlow::AuthorizationCode,
|
||||||
@@ -153,8 +159,14 @@ pub async fn login(
|
|||||||
.add_scope(Scope::new("openid".to_string()))
|
.add_scope(Scope::new("openid".to_string()))
|
||||||
.add_scope(Scope::new("profile".to_string()))
|
.add_scope(Scope::new("profile".to_string()))
|
||||||
.add_scope(Scope::new("email".to_string()))
|
.add_scope(Scope::new("email".to_string()))
|
||||||
.set_pkce_challenge(pkce_challenge)
|
.set_pkce_challenge(pkce_challenge);
|
||||||
.url();
|
// What else to ask the provider for: `phoneNumber` for one that
|
||||||
|
// verifies numbers (Vipps). Only when asked, because Kanidm denies
|
||||||
|
// a sign-in that requests a scope the person does not hold.
|
||||||
|
for scope in std::env::var("OIDC_EXTRA_SCOPES").unwrap_or_default().split_whitespace() {
|
||||||
|
request = request.add_scope(Scope::new(scope.to_string()));
|
||||||
|
}
|
||||||
|
let (auth_url, csrf_state, nonce) = request.url();
|
||||||
|
|
||||||
session
|
session
|
||||||
.insert(PKCE_KEY, pkce_verifier.secret())
|
.insert(PKCE_KEY, pkce_verifier.secret())
|
||||||
@@ -241,12 +253,34 @@ 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 user = User {
|
let email = claims.email().map(|e| e.as_str().trim().to_lowercase());
|
||||||
|
// The verified phone number lives at userinfo, not in the ID token,
|
||||||
|
// for providers that give one (Vipps). A provider that has none, or
|
||||||
|
// no userinfo endpoint, leaves it empty; nothing depends on it.
|
||||||
|
let phone = match oidc.client.user_info(token_response.access_token().clone(), None) {
|
||||||
|
Ok(request) => request
|
||||||
|
.request_async(&oidc.http)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())),
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
let mut user = User {
|
||||||
sub: claims.subject().as_str().to_string(),
|
sub: claims.subject().as_str().to_string(),
|
||||||
username,
|
username,
|
||||||
display_name,
|
display_name,
|
||||||
groups,
|
groups,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
};
|
};
|
||||||
|
// What they belong to, as portal keeps it: every invitation for
|
||||||
|
// them becomes a membership now, and their active memberships join
|
||||||
|
// whatever groups the provider itself asserted.
|
||||||
|
for group in crate::memberships::on_signed_in(&state, &user).await {
|
||||||
|
if !user.groups.contains(&group) {
|
||||||
|
user.groups.push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rotate the session id on privilege change, then store the user.
|
// Rotate the session id on privilege change, then store the user.
|
||||||
session.cycle_id().await.map_err(internal)?;
|
session.cycle_id().await.map_err(internal)?;
|
||||||
@@ -258,6 +292,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))
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-41
@@ -298,47 +298,7 @@ main.not-found {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The way down the tree: one level toward the front door, from any
|
/* The way down the tree: one level toward the front door, from any
|
||||||
page. Fixed at the left edge, half-way up, over the margin rather
|
page, as a line at the foot of it. */
|
||||||
than the text; small, and half-faded until touched, so it never
|
|
||||||
covers what a visitor is reading. */
|
|
||||||
.down-the-tree {
|
|
||||||
position: fixed;
|
|
||||||
left: 0.6rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: 20;
|
|
||||||
width: 2.6rem;
|
|
||||||
height: 2.6rem;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--card-bg-opaque);
|
|
||||||
color: var(--ink);
|
|
||||||
box-shadow: 0 0.2rem 0.8rem var(--card-shadow);
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 120ms, transform 120ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.down-the-tree:hover,
|
|
||||||
.down-the-tree:focus-visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-50%) translateX(-0.12rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.down-the-tree svg {
|
|
||||||
width: 1.3rem;
|
|
||||||
height: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 43.75rem) {
|
|
||||||
.down-the-tree {
|
|
||||||
left: 0.25rem;
|
|
||||||
width: 2.2rem;
|
|
||||||
height: 2.2rem;
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.down-the-tree-foot {
|
.down-the-tree-foot {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -358,6 +318,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;
|
||||||
@@ -1067,6 +1094,22 @@ textarea:focus {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The page about a listed record, when the list offers one. */
|
||||||
|
.answer-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 0.1rem solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-link:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.answer-actions {
|
.answer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -153,3 +153,98 @@ async fn a_session_survives_the_process_that_made_it() {
|
|||||||
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
|
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
|
||||||
next_boot.delete(&collide.id).await.ok();
|
next_boot.delete(&collide.id).await.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A membership is a case portal keeps itself: invited by a desk or a
|
||||||
|
/// decision, active from the person's first sign-in, removed by a desk,
|
||||||
|
/// and invited again after that. What the person belongs to is read
|
||||||
|
/// back from the same records, matched on phone number or address.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back() {
|
||||||
|
use portal::memberships::{self, Identity, ACTIVE, INVITED, REMOVED};
|
||||||
|
|
||||||
|
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
|
||||||
|
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let nats = async_nats::connect(&url).await.expect("connect");
|
||||||
|
let js = async_nats::jetstream::new(nats.clone());
|
||||||
|
ensure_stream(&js).await.expect("event stream");
|
||||||
|
// A clean bucket per run.
|
||||||
|
let _ = js.delete_key_value(memberships::BUCKET).await;
|
||||||
|
let aggregates = portal::content::with_builtin_aggregates(Default::default());
|
||||||
|
let questions = std::collections::HashMap::new();
|
||||||
|
let access = portal::access::Policy::from_content(&questions, &aggregates);
|
||||||
|
let state = portal::server::AppState {
|
||||||
|
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
|
||||||
|
nats,
|
||||||
|
jetstream: js,
|
||||||
|
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(questions)),
|
||||||
|
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)),
|
||||||
|
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(Default::default())),
|
||||||
|
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
|
||||||
|
gitea_base: String::new(),
|
||||||
|
content_raw_base: String::new(),
|
||||||
|
content_branch: "main".into(),
|
||||||
|
oidc: std::sync::Arc::new(portal::server::oidc::Oidc::disabled()),
|
||||||
|
garage: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Groups of this run's own: the event log outlives the bucket, and
|
||||||
|
// a record id seen before would replay its old history.
|
||||||
|
let run = chrono::Utc::now().timestamp_millis();
|
||||||
|
let styret = format!("styret{run}");
|
||||||
|
let medlemmer = format!("medlemmer{run}");
|
||||||
|
let (styret, medlemmer) = (styret.as_str(), medlemmer.as_str());
|
||||||
|
|
||||||
|
// A desk invites Kari by phone number; a decision grants her a
|
||||||
|
// second group by address. Both are invitations until she is seen.
|
||||||
|
let by_phone = Identity { sub: None, phone: Some("+4791234567".into()), email: None };
|
||||||
|
let by_mail = Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) };
|
||||||
|
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant");
|
||||||
|
assert!(first.created && first.reset_link.is_none());
|
||||||
|
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant again");
|
||||||
|
assert!(!again.created, "a second invite into the same group finds the first");
|
||||||
|
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc").await.expect("grant by mail");
|
||||||
|
let records = memberships::all(&state).await;
|
||||||
|
assert_eq!(records.len(), 2);
|
||||||
|
assert!(records.iter().all(|r| r.state == INVITED));
|
||||||
|
assert!(memberships::groups_of(&records, &by_phone).is_empty(), "an invitation is not yet a group");
|
||||||
|
|
||||||
|
// She signs in: the provider knows her subject, phone and address.
|
||||||
|
let kari = portal::auth::User {
|
||||||
|
sub: "vipps-abc".into(),
|
||||||
|
username: "kari".into(),
|
||||||
|
display_name: "Kari".into(),
|
||||||
|
groups: vec![],
|
||||||
|
phone: Some("912 34 567".into()),
|
||||||
|
email: Some("Kari@Vel.no".into()),
|
||||||
|
};
|
||||||
|
let mut groups = memberships::on_signed_in(&state, &kari).await;
|
||||||
|
groups.sort();
|
||||||
|
assert_eq!(groups, vec![medlemmer, styret]);
|
||||||
|
let records = memberships::all(&state).await;
|
||||||
|
assert!(records.iter().all(|r| r.state == ACTIVE));
|
||||||
|
// Both records now know her by everything the provider verified.
|
||||||
|
for r in &records {
|
||||||
|
let held = memberships::identity_of(r);
|
||||||
|
assert_eq!(held.sub.as_deref(), Some("vipps-abc"));
|
||||||
|
assert_eq!(held.phone.as_deref(), Some("+4791234567"));
|
||||||
|
assert_eq!(held.email.as_deref(), Some("kari@vel.no"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A desk takes her out of styret; the record stays, as removed.
|
||||||
|
assert!(memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke"));
|
||||||
|
assert!(!memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke twice"), "already out");
|
||||||
|
let records = memberships::all(&state).await;
|
||||||
|
let styret_record = records.iter().find(|r| memberships::group_of(r) == styret).unwrap();
|
||||||
|
assert_eq!(styret_record.state, REMOVED);
|
||||||
|
assert_eq!(memberships::groups_of(&records, &Identity { sub: Some("vipps-abc".into()), phone: None, email: None }), vec![medlemmer]);
|
||||||
|
|
||||||
|
// Invited again: the same record, invited, and hers at the next sign-in.
|
||||||
|
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk").await.expect("re-grant");
|
||||||
|
assert!(back.created);
|
||||||
|
let records = memberships::all(&state).await;
|
||||||
|
assert_eq!(records.iter().find(|r| memberships::group_of(r) == styret).unwrap().state, INVITED);
|
||||||
|
let groups = memberships::on_signed_in(&state, &kari).await;
|
||||||
|
assert!(groups.contains(&styret.to_string()));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user