Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
738dbac7fe | ||
|
|
98f93f5ad5 | ||
|
|
4049809aa4 | ||
|
|
b43ff57eab | ||
|
|
ed235cd134 | ||
|
|
cc9f1c5d87 | ||
|
|
01abf79005 | ||
|
|
506bb712fe | ||
|
|
74fd2d1750 | ||
|
|
ae521c0fa2 | ||
|
|
d0ee06f48c | ||
|
|
6a9be3bef4 | ||
|
|
49be010e8e |
@@ -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
|
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.6.16 (2026-10-01)
|
||||||
|
|
||||||
|
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
|
||||||
|
- `portal iris` is the lint's name, after the iris that keeps bad content out of the portal. `portal lint` is the same command and keeps working.
|
||||||
|
|
||||||
|
## 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)
|
## 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.
|
- 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.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.6.12"
|
version = "0.6.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.6.12"
|
version = "0.6.15"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ answer:
|
|||||||
from: [open, incomplete] # defaults to the initial state alone
|
from: [open, incomplete] # defaults to the initial state alone
|
||||||
label: Send them
|
label: Send them
|
||||||
invite: { group: case_officers } # onboard someone into Kanidm
|
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
|
`self_transition.from` is what keeps a person in dialogue with their
|
||||||
|
|||||||
+5
-1
@@ -4,11 +4,15 @@ The iris over a site. A content push is an incoming wormhole; nothing
|
|||||||
comes through until it checks out.
|
comes through until it checks out.
|
||||||
|
|
||||||
```
|
```
|
||||||
portal lint --path questions # a local checkout
|
portal iris --path questions # a local checkout
|
||||||
portal lint --repo https://project.uhhm.no/uhhm/questions
|
portal lint --repo https://project.uhhm.no/uhhm/questions
|
||||||
portal lint replay --path questions --nats nats://127.0.0.1:14222
|
portal lint replay --path questions --nats nats://127.0.0.1:14222
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`portal iris` is the lint's name, after the iris that keeps bad content
|
||||||
|
out of the portal; `portal lint` runs the same thing, so workflows that
|
||||||
|
already call it keep working.
|
||||||
|
|
||||||
It was its own binary, iris, until portal 0.6: the same code, released
|
It was its own binary, iris, until portal 0.6: the same code, released
|
||||||
as portal's twin. Now it is the `portal` binary's `lint` command, so a
|
as portal's twin. Now it is the `portal` binary's `lint` command, so a
|
||||||
content repo's CI downloads the release and runs it:
|
content repo's CI downloads the release and runs it:
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ slideshow:
|
|||||||
- image: images/bildeserie/20.webp
|
- image: images/bildeserie/20.webp
|
||||||
width: 1867
|
width: 1867
|
||||||
height: 1400
|
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
|
- image: images/bildeserie/21.webp
|
||||||
width: 1867
|
width: 1867
|
||||||
height: 1400
|
height: 1400
|
||||||
@@ -309,4 +309,4 @@ slideshow:
|
|||||||
- image: images/bildeserie/25.webp
|
- image: images/bildeserie/25.webp
|
||||||
width: 1487
|
width: 1487
|
||||||
height: 1400
|
height: 1400
|
||||||
caption: pH-test av borprøver i mur fra forskjellige dybder
|
caption: pH-test av borprøver i betong fra forskjellige dybder
|
||||||
|
|||||||
+7
-1
@@ -3082,10 +3082,16 @@ pub async fn submit_answer(
|
|||||||
let Some((name, who)) = crate::people::who_for(&responses) else {
|
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"));
|
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))
|
.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}")))?;
|
||||||
|
// 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() {
|
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());
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -391,6 +391,13 @@ pub struct Alternative {
|
|||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Invite {
|
pub struct Invite {
|
||||||
pub group: String,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -1316,6 +1323,7 @@ alternatives:
|
|||||||
public: true
|
public: true
|
||||||
source: {{ kind: kv, bucket: things }}
|
source: {{ kind: kv, bucket: things }}
|
||||||
- name: Object
|
- name: Object
|
||||||
|
action: /object
|
||||||
record_as: things
|
record_as: things
|
||||||
features:
|
features:
|
||||||
- name: Yours
|
- name: Yours
|
||||||
@@ -1413,6 +1421,7 @@ id: /t
|
|||||||
name: Where is it?
|
name: Where is it?
|
||||||
alternatives:
|
alternatives:
|
||||||
- name: A
|
- name: A
|
||||||
|
action: /t
|
||||||
features:
|
features:
|
||||||
- name: Where
|
- name: Where
|
||||||
requirements:
|
requirements:
|
||||||
@@ -1516,6 +1525,7 @@ id: /b
|
|||||||
name: B
|
name: B
|
||||||
alternatives:
|
alternatives:
|
||||||
- name: A
|
- name: A
|
||||||
|
action: /b
|
||||||
features:
|
features:
|
||||||
- name: ""
|
- name: ""
|
||||||
requirements:
|
requirements:
|
||||||
@@ -1557,7 +1567,7 @@ alternatives:
|
|||||||
|
|
||||||
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
|
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||||
let question: Question = serde_yaml::from_str(&format!(
|
let question: Question = serde_yaml::from_str(&format!(
|
||||||
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
|
"id: /g\nname: G\nalternatives:\n - name: A\n action: /g\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||||
@@ -1828,7 +1838,7 @@ alternatives:
|
|||||||
fn recorded_buckets_must_be_attended() {
|
fn recorded_buckets_must_be_attended() {
|
||||||
let write_only = vec![(
|
let write_only = vec![(
|
||||||
"index.yaml".to_string(),
|
"index.yaml".to_string(),
|
||||||
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
|
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n".to_string(),
|
||||||
)];
|
)];
|
||||||
let questions = build_questions(&write_only).unwrap();
|
let questions = build_questions(&write_only).unwrap();
|
||||||
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||||
@@ -1839,7 +1849,7 @@ alternatives:
|
|||||||
let with_desk = vec![
|
let with_desk = vec![
|
||||||
(
|
(
|
||||||
"index.yaml".to_string(),
|
"index.yaml".to_string(),
|
||||||
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
|
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: inbox\n".to_string(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"review/index.yaml".to_string(),
|
"review/index.yaml".to_string(),
|
||||||
|
|||||||
@@ -512,6 +512,20 @@ pub fn validate_questions(
|
|||||||
}
|
}
|
||||||
for question in questions.values() {
|
for question in questions.values() {
|
||||||
for alternative in &question.alternatives {
|
for alternative in &question.alternatives {
|
||||||
|
// A form is sent with its button, and the button is only
|
||||||
|
// drawn for an alternative that says where to go afterwards:
|
||||||
|
// without `action` the person fills it in and cannot send it.
|
||||||
|
let asks = alternative.features.iter().any(|f| f.requirements.iter().any(|r| r.kind != "hidden"));
|
||||||
|
if (asks || alternative.invite.is_some() || alternative.record_as.is_some())
|
||||||
|
&& alternative.action.is_none()
|
||||||
|
&& alternative.self_transition.is_none()
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"question {:?} alternative {:?}: it has a form to send but no `action`, so no button is drawn; give it `action:`, the page to go to once it is sent (its own page is fine)",
|
||||||
|
question.id,
|
||||||
|
alternative.name
|
||||||
|
);
|
||||||
|
}
|
||||||
if alternative.one_per_person && alternative.record_as.is_none() {
|
if alternative.one_per_person && alternative.record_as.is_none() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
|
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
|
||||||
|
|||||||
+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())
|
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut vars = serde_json::Map::new();
|
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("name".into(), invited.display_name.clone().into());
|
||||||
vars.insert("username".into(), invited.username.clone().into());
|
vars.insert("username".into(), invited.username.clone().into());
|
||||||
vars.insert("questions".into(), list.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
|
/// 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}`,
|
||||||
/// `{site}`, `{expires}`.
|
/// `{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) {
|
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
|
||||||
let site = state.site.load();
|
let site = state.site.load();
|
||||||
let Some(cfg) = &site.mail else {
|
let Some(cfg) = &site.mail else {
|
||||||
@@ -560,7 +587,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
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()),
|
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
|
||||||
(None, true, true) => (
|
(None, true, true) => (
|
||||||
"Du er invitert inn i {group} på {site_name}".to_string(),
|
"Du er invitert inn i {group} på {site_name}".to_string(),
|
||||||
@@ -662,6 +689,13 @@ mod tests {
|
|||||||
assert_eq!(vars["state"], "fixed");
|
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]
|
#[test]
|
||||||
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
|
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)");
|
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
|
||||||
|
|||||||
+4
-3
@@ -1,5 +1,6 @@
|
|||||||
/// One binary, three jobs: `portal` (or `portal serve`) runs a site;
|
/// One binary, three jobs: `portal` (or `portal serve`) runs a site;
|
||||||
/// `portal lint` checks a content repo the way the site reads it;
|
/// `portal iris` checks a content repo the way the site reads it (the
|
||||||
|
/// iris keeps bad content out of the portal; `portal lint` is the same);
|
||||||
/// `portal plan`, `build`, `report` and the rest train one. The lint
|
/// `portal plan`, `build`, `report` and the rest train one. The lint
|
||||||
/// and the trainer are the same code as the server, at the same
|
/// and the trainer are the same code as the server, at the same
|
||||||
/// version, so they never disagree about what content means.
|
/// version, so they never disagree about what content means.
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||||
match argv.first().map(String::as_str) {
|
match argv.first().map(String::as_str) {
|
||||||
Some("lint") => return portal::lint::run(argv[1..].to_vec()).await,
|
Some("iris" | "lint") => return portal::lint::run(argv[1..].to_vec()).await,
|
||||||
Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => {
|
Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => {
|
||||||
// The trainer runs models through shell commands and waits
|
// The trainer runs models through shell commands and waits
|
||||||
// on them; off the runtime's threads, as a CLI should.
|
// on them; off the runtime's threads, as a CLI should.
|
||||||
@@ -19,7 +20,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Some("serve") | None => {}
|
Some("serve") | None => {}
|
||||||
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, lint, plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
|
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, iris (or lint), plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
|
||||||
}
|
}
|
||||||
serve().await
|
serve().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,6 +254,8 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
|
|||||||
reset_link: None,
|
reset_link: None,
|
||||||
link_expires_ms: None,
|
link_expires_ms: None,
|
||||||
created,
|
created,
|
||||||
|
mail: None,
|
||||||
|
fields: Default::default(),
|
||||||
already_member: !created,
|
already_member: !created,
|
||||||
};
|
};
|
||||||
match existing {
|
match existing {
|
||||||
|
|||||||
@@ -127,6 +127,12 @@ pub struct Invited {
|
|||||||
/// the membership is not portal's to take back.
|
/// the membership is not portal's to take back.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub already_member: bool,
|
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
|
/// When a link runs out, from Kanidm's `expiry_time`: seconds since
|
||||||
@@ -367,6 +373,8 @@ impl Kanidm {
|
|||||||
reset_link,
|
reset_link,
|
||||||
link_expires_ms,
|
link_expires_ms,
|
||||||
created,
|
created,
|
||||||
|
mail: None,
|
||||||
|
fields: Default::default(),
|
||||||
already_member,
|
already_member,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,6 +410,8 @@ mod tests {
|
|||||||
reset_link: None,
|
reset_link: None,
|
||||||
link_expires_ms: None,
|
link_expires_ms: None,
|
||||||
created: true,
|
created: true,
|
||||||
|
mail: None,
|
||||||
|
fields: Default::default(),
|
||||||
already_member,
|
already_member,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+12
-1
@@ -162,10 +162,13 @@ main.not-found {
|
|||||||
|
|
||||||
/* hero */
|
/* 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 {
|
.hero {
|
||||||
max-width: 46rem;
|
max-width: 46rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 4rem 1.5rem 2.5rem;
|
padding: 1.75rem 1.5rem 2.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -228,6 +231,14 @@ main.not-found {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 34rem) {
|
@media (max-width: 34rem) {
|
||||||
|
.hero {
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-bar {
|
||||||
|
padding-block: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-top {
|
.hero-top {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user