Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc9f1c5d87 | ||
|
|
01abf79005 | ||
|
|
506bb712fe | ||
|
|
74fd2d1750 | ||
|
|
ae521c0fa2 | ||
|
|
d0ee06f48c | ||
|
|
6a9be3bef4 | ||
|
|
49be010e8e | ||
|
|
78f42e6f8a | ||
|
|
9b50c9cab8 | ||
|
|
858a66d84e |
@@ -4,6 +4,19 @@ 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
|
||||
content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## 0.6.15 (2026-10-01)
|
||||
|
||||
- An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good.
|
||||
|
||||
## 0.6.14 (2026-10-01)
|
||||
|
||||
- Less room above the wordmark: 1.75rem, 1.25rem on a narrow screen (it was 4rem). The space was kept for who is here, which now sits on the wordmark's own line, and on a tall, narrow screen it read as dead space.
|
||||
|
||||
## 0.6.13 (2026-10-01)
|
||||
|
||||
- Who is here (the name and "sign out", or "sign in") sits on the wordmark's own line instead of being pinned to the corner of the page, where it read as a row of its own. With a picture strip it is in the wordmark's bar; without, it is level with the mark at the right edge. On a narrow screen it drops under the mark.
|
||||
- Trainer: every form, public ones included, carries a signed-in person's name and address from the login instead of asking for them; a visitor still sees the fields. Only the inside forms did before, so someone signed in filled in their own name and address again on a public form.
|
||||
|
||||
## 0.6.12 (2026-10-01)
|
||||
|
||||
- Trainer: the words model is told who opens each mail: the person who sent the case, the person who posted the record an answer is about, or someone at the business. Mails to the business about another person's case used to be written as if to that person ("you will hear from us"); on Gullberg, three of nine were.
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.6.12"
|
||||
version = "0.6.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.6.12"
|
||||
version = "0.6.14"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -125,6 +125,10 @@ answer:
|
||||
from: [open, incomplete] # defaults to the initial state alone
|
||||
label: Send them
|
||||
invite: { group: case_officers } # onboard someone into Kanidm
|
||||
# or with words of its own, using any field of the form:
|
||||
# invite:
|
||||
# group: guests
|
||||
# mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." }
|
||||
```
|
||||
|
||||
`self_transition.from` is what keeps a person in dialogue with their
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ needs:
|
||||
# Offentlig: noen søker rom. Studenter går foran; styret ser hva den
|
||||
# som søker gjør til daglig, og velger etter det.
|
||||
- id: leie-rom
|
||||
who: en som trenger et rimelig rom på Ås, oftest en student ved NMBU
|
||||
who: en som trenger et rimelig rom i Ås, oftest en student ved NMBU
|
||||
wants: et rom å bo på til en leie som går opp, med studenter først i køen
|
||||
lands_in: romsoknader
|
||||
handled_by: styret
|
||||
|
||||
@@ -289,7 +289,7 @@ slideshow:
|
||||
- image: images/bildeserie/20.webp
|
||||
width: 1867
|
||||
height: 1400
|
||||
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i 10 år.
|
||||
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i minst 10 år.
|
||||
- image: images/bildeserie/21.webp
|
||||
width: 1867
|
||||
height: 1400
|
||||
@@ -309,4 +309,4 @@ slideshow:
|
||||
- image: images/bildeserie/25.webp
|
||||
width: 1487
|
||||
height: 1400
|
||||
caption: pH-test av borprøver i mur fra forskjellige dybder
|
||||
caption: pH-test av borprøver i betong fra forskjellige dybder
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
}
|
||||
},
|
||||
"questions/index.yaml": {
|
||||
"question": "Trenger du et rimelig rom på Ås?",
|
||||
"question": "Trenger du et rimelig rom i Ås?",
|
||||
"description": "Gullberg er et gammelt hus bak Ås kirke, et kort stykke fra NMBU. Eierne driver det selv, og Klingenberg Bygg forvalter huset og vedlikeholdet.",
|
||||
"opener": {
|
||||
"heading": "Et rimelig sted å bo, nær NMBU",
|
||||
|
||||
+14
-4
@@ -998,10 +998,12 @@ fn Hero(
|
||||
}
|
||||
};
|
||||
|
||||
let top = view! { <div class="hero-top">{mark()}{session}</div> };
|
||||
let (bar_top, copy_top) = if has_slides { (Some(top), None) } else { (None, Some(top)) };
|
||||
|
||||
view! {
|
||||
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
|
||||
<Announcements current_id=current_id/>
|
||||
{session}
|
||||
{has_module
|
||||
.then(|| {
|
||||
view! {
|
||||
@@ -1012,10 +1014,12 @@ fn Hero(
|
||||
// With pictures, the wordmark is a bar of its own above
|
||||
// them and the question comes under; without, the wordmark
|
||||
// heads the copy as it always has.
|
||||
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
|
||||
// Who is here sits on the wordmark's own line, not on a row
|
||||
// of its own above it.
|
||||
{bar_top.map(|top| view! { <div class="hero-bar">{top}</div> })}
|
||||
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
|
||||
<div class="hero-copy">
|
||||
{(!has_slides).then(|| mark())}
|
||||
{copy_top}
|
||||
<h1>{title}</h1>
|
||||
<p inner_html=render_inline_markdown(&description)></p>
|
||||
</div>
|
||||
@@ -3078,10 +3082,16 @@ pub async fn submit_answer(
|
||||
let Some((name, who)) = crate::people::who_for(&responses) else {
|
||||
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
|
||||
};
|
||||
let invited = people
|
||||
let mut invited = people
|
||||
.invite(&state, &name, &who, &invite.group, &principal_name(&principal))
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
|
||||
// This invite's own words, and the form they draw on: kept with
|
||||
// the invite, so sending it again says the same thing.
|
||||
invited.mail = invite.mail.clone();
|
||||
if invited.mail.is_some() {
|
||||
invited.fields = responses.as_object().cloned().unwrap_or_default();
|
||||
}
|
||||
if let Some(map) = responses.as_object_mut() {
|
||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||
}
|
||||
|
||||
@@ -391,6 +391,13 @@ pub struct Alternative {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Invite {
|
||||
pub group: String,
|
||||
/// This invite's own mail, in place of the site's (`mail.invite`):
|
||||
/// an invitation to a conversation at an hour, say. Besides
|
||||
/// `{name}`, `{link}`, `{expires}`, `{username}`, `{group}`,
|
||||
/// `{site}` and `{site_name}`, every field of the form is a
|
||||
/// placeholder; a date and time is written out in words.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mail: Option<MailTemplate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
+35
-1
@@ -416,6 +416,14 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_default();
|
||||
let mut vars = serde_json::Map::new();
|
||||
// The form's own fields first, so the invite's fixed placeholders
|
||||
// win a clash; a date and time reads as one.
|
||||
for (key, value) in &invited.fields {
|
||||
let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
|
||||
if let Some(text) = text {
|
||||
vars.insert(key.clone(), text.into());
|
||||
}
|
||||
}
|
||||
vars.insert("name".into(), invited.display_name.clone().into());
|
||||
vars.insert("username".into(), invited.username.clone().into());
|
||||
vars.insert("questions".into(), list.into());
|
||||
@@ -553,6 +561,25 @@ pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &s
|
||||
/// Built in, since portal knows everything about it; `mail.invite` in
|
||||
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
|
||||
/// `{site}`, `{expires}`.
|
||||
/// A form's date and time (`2026-10-06T15:00`, from a `datetime-local`
|
||||
/// field) as a person says it: "tirsdag 6. oktober kl. 15:00", "Tuesday
|
||||
/// 6 October at 15:00". Anything else is left as it is.
|
||||
pub fn spoken_datetime(value: &str, norwegian: bool) -> Option<String> {
|
||||
use chrono::{Datelike, NaiveDateTime, Timelike};
|
||||
let t = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M").ok()?;
|
||||
let day = t.weekday().num_days_from_monday() as usize;
|
||||
let month = t.month0() as usize;
|
||||
Some(if norwegian {
|
||||
const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"];
|
||||
const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
|
||||
format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
|
||||
} else {
|
||||
const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
@@ -560,7 +587,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
||||
return;
|
||||
};
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) {
|
||||
let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) {
|
||||
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
|
||||
(None, true, true) => (
|
||||
"Du er invitert inn i {group} på {site_name}".to_string(),
|
||||
@@ -662,6 +689,13 @@ mod tests {
|
||||
assert_eq!(vars["state"], "fixed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_and_time_from_a_form_reads_as_a_person_says_it() {
|
||||
assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00"));
|
||||
assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00"));
|
||||
assert_eq!(spoken_datetime("Anders Kluge", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
|
||||
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
|
||||
|
||||
@@ -254,6 +254,8 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member: !created,
|
||||
};
|
||||
match existing {
|
||||
|
||||
@@ -127,6 +127,12 @@ pub struct Invited {
|
||||
/// the membership is not portal's to take back.
|
||||
#[serde(default)]
|
||||
pub already_member: bool,
|
||||
/// The invite's own mail and the form it was sent from, kept with
|
||||
/// the invite so that sending it again sends the same words.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mail: Option<crate::content::MailTemplate>,
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub fields: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// When a link runs out, from Kanidm's `expiry_time`: seconds since
|
||||
@@ -367,6 +373,8 @@ impl Kanidm {
|
||||
reset_link,
|
||||
link_expires_ms,
|
||||
created,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -410,6 +410,8 @@ mod tests {
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created: true,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1953,13 +1953,12 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
|
||||
if f.multiple { r["multiple"] = json!(true); }
|
||||
let hint = s(&w["hints"][&f.name]).trim();
|
||||
if !hint.is_empty() && f.kind != "select" { r["placeholder"] = json!(hint); }
|
||||
// Someone signed in is not asked for what the site knows: on a
|
||||
// group's page they always are, and answering a record they
|
||||
// may be.
|
||||
if parent.is_some() || need.enters_as.is_some() {
|
||||
if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); }
|
||||
if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); }
|
||||
}
|
||||
// Someone signed in is not asked for what the site knows, on any
|
||||
// form: portal carries their name and address and the fields take
|
||||
// no room (`content::fill_from_user`). A visitor sees the fields
|
||||
// as usual, so a public form loses nothing by it.
|
||||
if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); }
|
||||
if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); }
|
||||
r
|
||||
}).collect();
|
||||
// The record this answer is about, carried in from the page's own
|
||||
|
||||
+53
-5
@@ -162,10 +162,13 @@ main.not-found {
|
||||
|
||||
/* hero */
|
||||
|
||||
/* No room is kept at the top for who is here: that sits on the
|
||||
wordmark's own line now (.hero-top), and the 4rem it used to need
|
||||
read as dead space above the mark on a tall, narrow screen. */
|
||||
.hero {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 2.5rem;
|
||||
padding: 1.75rem 1.5rem 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -176,10 +179,6 @@ main.not-found {
|
||||
/* Who is here and the way in or out: top right of every page, on a
|
||||
site that has sign-in. */
|
||||
.session {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1.5rem;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
align-items: baseline;
|
||||
@@ -201,6 +200,55 @@ main.not-found {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* The wordmark's line, with who is here on it: the mark centred and the
|
||||
session at the right edge, level with the mark. A skin that sets
|
||||
everything on one left edge spreads the two apart instead (see
|
||||
Klingenberg Bygg's and Gullberg's). On a narrow screen the session
|
||||
drops under the mark rather than over it. */
|
||||
.hero-top {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-top .session {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.hero-bar .hero-top {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero-bar .hero-top .session {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.hero {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-bar {
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-top .session {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A page with pictures: the wordmark is a bar of its own, the strip
|
||||
runs the full width under it, and the question follows. */
|
||||
.hero-slides {
|
||||
|
||||
Reference in New Issue
Block a user