Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b54c4c6d7 | ||
|
|
951fac9aec | ||
|
|
738dbac7fe | ||
|
|
98f93f5ad5 | ||
|
|
4049809aa4 | ||
|
|
b43ff57eab | ||
|
|
ed235cd134 | ||
|
|
cc9f1c5d87 | ||
|
|
01abf79005 | ||
|
|
506bb712fe |
@@ -4,6 +4,15 @@ 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)
|
## 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.
|
- 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.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.6.14"
|
version = "0.6.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.6.14"
|
version = "0.6.16"
|
||||||
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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user