lib.rs
⎇
Raw
1//! The HTTP wire contract of dovenest in one place.
2//!
3//! Both the server (axum) and the web frontend (wasm `fetch`) import these
4//! endpoint paths, query params and serde types, so the two sides cannot
5//! drift apart. Serde only — no axum, no wasm dependencies.
6
7use serde::{Deserialize, Serialize};
8
9// ---------------------------------------------------------------------------
10// Endpoint paths (single source of truth for the route table and the client)
11// ---------------------------------------------------------------------------
12
13pub const AUTH_LOGIN: &str = "/api/auth/login";
14pub const AUTH_LOGOUT: &str = "/api/auth/logout";
15pub const AUTH_ME: &str = "/api/auth/me";
16pub const AUTH_SETUP: &str = "/api/auth/setup";
17/// Change or set the signed-in user's password (`POST`), or remove it
18/// (`DELETE`, passkey-only accounts).
19pub const AUTH_PASSWORD: &str = "/api/auth/password";
20/// `PUT` the signed-in user's sign-in requirement ([`AuthMode`]).
21pub const AUTH_MODE: &str = "/api/auth/mode";
22/// The signed-in user's passkeys: `GET {AUTH_PASSKEYS}` lists them,
23/// `DELETE {AUTH_PASSKEYS}/{id}` removes one.
24pub const AUTH_PASSKEYS: &str = "/api/auth/passkeys";
25/// Start registering a new passkey (`POST`). Finished at
26/// `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
27pub const AUTH_PASSKEYS_REGISTER: &str = "/api/auth/passkeys/register";
28/// Start a passkey sign-in (`POST`, no session needed). Finished at
29/// `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
30pub const AUTH_PASSKEY_LOGIN: &str = "/api/auth/passkey/login";
31/// The signed-in user's WebDAV app passwords: `GET {AUTH_APP_PASSWORDS}`
32/// lists them, `POST` creates one, `DELETE {AUTH_APP_PASSWORDS}/{id}` revokes
33/// one.
34pub const AUTH_APP_PASSWORDS: &str = "/api/auth/app-passwords";
35/// Second leg of both WebAuthn ceremonies: the browser's answer goes to the
36/// begin path plus this suffix.
37pub const FINISH_SUFFIX: &str = "/finish";
38/// File operations: `{FILES}/{root_id}` and `{FILES}/{root_id}/{path...}`.
39pub const FILES: &str = "/api/files";
40/// Share management (authenticated): `{SHARES}` and `{SHARES}/{id}`.
41pub const SHARES: &str = "/api/shares";
42/// Public share resolve (no login): `{SHARE}/{token}`.
43pub const SHARE: &str = "/api/share";
44/// Suffix on `{SHARE}/{token}`: submit the password of a protected share.
45pub const SHARE_UNLOCK_SUFFIX: &str = "/unlock";
46/// `GET /api/search` — name and/or content search, streamed as SSE.
47pub const SEARCH: &str = "/api/search";
48
49/// WebDAV mount of the signed-in user's roots: `{DAV}` and `{DAV}/{path...}`.
50pub const DAV: &str = "/dav";
51/// WebDAV mount of one public share: `{DAV_SHARE}/{token}/{path...}`.
52///
53/// A separate top-level path, not a segment under [`DAV`]: there, the first
54/// segment is a root's display name, which a reserved word could collide with.
55pub const DAV_SHARE: &str = "/dav-share";
56
57/// CalDAV and CardDAV: principals, calendars and address books.
58///
59/// Not under [`DAV`] for the same reason as [`DAV_SHARE`].
60pub const PIM: &str = "/pim";
61/// RFC 6764 discovery. Both redirect to [`PIM`].
62pub const WELL_KNOWN_CALDAV: &str = "/.well-known/caldav";
63pub const WELL_KNOWN_CARDDAV: &str = "/.well-known/carddav";
64
65/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
66pub const ADMIN_USERS: &str = "/api/admin/users";
67/// Admin view of every share on the server: `{ADMIN_SHARES}` and
68/// `{ADMIN_SHARES}/{id}`. [`SHARES`] is the same data scoped to the caller.
69pub const ADMIN_SHARES: &str = "/api/admin/shares";
70pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
71/// Admin management of rooms and resources: `{ADMIN_ROOMS}` and
72/// `{ADMIN_ROOMS}/{id}`.
73pub const ADMIN_ROOMS: &str = "/api/admin/rooms";
74/// Admin view of every public calendar and address book feed:
75/// `{ADMIN_PIM_LINKS}` and `{ADMIN_PIM_LINKS}/{id}`.
76pub const ADMIN_PIM_LINKS: &str = "/api/admin/pim-links";
77/// The signed-in user's calendars and address books, own and lent to them
78/// (`GET`), and a new one (`POST`). `PUT` and `DELETE` on `{PIM_COLLECTIONS}/{id}`
79/// change or delete an own one; `DELETE` on a lent one ends the loan.
80/// `{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` lists (`GET`) and lends (`POST`)
81/// an own one; `DELETE` on `.../{user_id}` below it ends a loan.
82pub const PIM_COLLECTIONS: &str = "/api/pim/collections";
83pub const SHARES_SUFFIX: &str = "/shares";
84/// `{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`: the public feeds of an own
85/// collection (`GET`, `POST`); `DELETE` on `.../{link_id}` below it.
86pub const LINKS_SUFFIX: &str = "/links";
87/// `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}`: an `.ics` or `.vcf` body.
88pub const IMPORT_SUFFIX: &str = "/import";
89/// `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: the collection as one file.
90pub const EXPORT_SUFFIX: &str = "/export";
91/// `GET`: the system address book as one file. It has no collection id.
92pub const PIM_SYSTEM_EXPORT: &str = "/api/pim/system/export";
93/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`: one event or contact
94/// as [`PimObjectDetail`]. `{...}/{name}{PHOTO_SUFFIX}`: a contact's photo as
95/// a WebP thumbnail.
96pub const OBJECTS_SUFFIX: &str = "/objects";
97pub const PHOTO_SUFFIX: &str = "/photo";
98/// `GET`: the instances of the readable calendars in a time range, as
99/// [`PimInstances`]. Params `from`, `to` (RFC 3339), `tz`, `collections`.
100pub const PIM_INSTANCES: &str = "/api/pim/instances";
101/// `GET`: the contacts of the readable address books, as [`PimContact`]s.
102/// Params `q`, `collections`.
103pub const PIM_CONTACTS: &str = "/api/pim/contacts";
104/// `GET`: the invitations the signed-in user has not answered, as
105/// [`PimInvitation`]s. `POST` a [`PimReply`] to answer one.
106pub const PIM_INVITATIONS: &str = "/api/pim/invitations";
107/// Public feed of one calendar or address book: `{FEED}/{token}.ics` or
108/// `.vcf`. The extension is optional.
109pub const FEED: &str = "/feed";
110/// Pseudo root id every signed-in admin has on the files API: the whole
111/// server root, read-only (the admin folder picker browses it). Real root
112/// ids are positive database ids. Not listed in `/me`.
113pub const ADMIN_ROOT: i64 = -1;
114
115// ---------------------------------------------------------------------------
116// Query params
117// ---------------------------------------------------------------------------
118
119/// `?action=...` on file URLs; without it the route lists the directory.
120pub const P_ACTION: &str = "action";
121pub const ACTION_DOWNLOAD: &str = "download";
122pub const ACTION_PREVIEW: &str = "preview";
123pub const ACTION_CONTENT: &str = "content";
124pub const ACTION_THUMB: &str = "thumb";
125/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
126/// POST route also carries uploads and mutations.
127pub const ACTION_MKDIR: &str = "mkdir";
128/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
129/// like `mkdir`, for the same reason.
130pub const ACTION_CREATE_FILE: &str = "create-file";
131/// `POST {FILES}/...?action=exists` with an [`ExistsReq`] body — read-only
132/// pre-check for an upload: which of the given targets already exist.
133pub const ACTION_EXISTS: &str = "exists";
134/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
135pub const P_FORMAT: &str = "format";
136/// `?share=<token>` — authenticate file calls with a public share token.
137pub const P_SHARE: &str = "share";
138/// Search query text (`GET {SEARCH}`).
139pub const P_Q: &str = "q";
140/// Which index to search: `name`, `content` or `both`.
141pub const P_SCOPE: &str = "scope";
142/// Root id to search; omitted = the caller's first root.
143pub const P_ROOT: &str = "root";
144/// Folder inside the root to start a search in (relative to the root);
145/// omitted or empty = the whole root.
146pub const P_PATH: &str = "path";
147/// `?overwrite=true|1` on mutations and uploads.
148pub const P_OVERWRITE: &str = "overwrite";
149/// Listing order ([`SortKey`]); omitted = by name.
150pub const P_SORT: &str = "sort";
151/// `?desc=true` reverses the listing order. Folders still come first.
152pub const P_DESC: &str = "desc";
153/// First listing entry to return, in the sorted order.
154pub const P_OFFSET: &str = "offset";
155/// Listing entries to return, capped at [`MAX_LIST_ENTRIES`]; omitted = the cap.
156pub const P_LIMIT: &str = "limit";
157/// `?dirs=true` lists only the subfolders (the folder picker).
158pub const P_DIRS: &str = "dirs";
159/// `?around=name` returns the page that holds this entry, instead of the
160/// one at the offset. A missing name falls back to the offset.
161pub const P_AROUND: &str = "around";
162/// [`PIM_INSTANCES`]: the range, RFC 3339.
163pub const P_FROM: &str = "from";
164pub const P_TO: &str = "to";
165/// [`PIM_INSTANCES`], [`PIM_PREVIEW`], object detail: the IANA zone that
166/// all-day and floating times are read in. Default UTC.
167pub const P_TZ: &str = "tz";
168/// [`PIM_INSTANCES`], [`PIM_CONTACTS`]: comma-separated collection ids.
169/// Default all readable ones.
170pub const P_COLLECTIONS: &str = "collections";
171/// Object detail: the instance of a series, as in [`PimInstance`].
172pub const P_RECURRENCE_ID: &str = "recurrence_id";
173
174// ---------------------------------------------------------------------------
175// Wire enums
176// ---------------------------------------------------------------------------
177
178/// Access mode of a root or a share.
179///
180/// The serde names are also the values stored in the SQLite `mode` columns,
181/// so renaming a variant would break existing databases. The round-trip test
182/// below pins them.
183#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
184#[serde(rename_all = "lowercase")]
185pub enum Mode {
186 Rw,
187 Ro,
188}
189
190impl Mode {
191 /// The only question callers ask: may this root be written to?
192 pub fn is_writable(self) -> bool {
193 matches!(self, Mode::Rw)
194 }
195
196 /// The wire/database spelling, for `<select>` values and SQL params.
197 pub fn as_str(self) -> &'static str {
198 match self {
199 Mode::Rw => "rw",
200 Mode::Ro => "ro",
201 }
202 }
203
204 /// Parse the wire spelling. `None` for anything else.
205 pub fn from_wire(s: &str) -> Option<Self> {
206 match s {
207 "rw" => Some(Mode::Rw),
208 "ro" => Some(Mode::Ro),
209 _ => None,
210 }
211 }
212}
213
214/// Which mutation [`Mutation`] asks for.
215#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
216#[serde(rename_all = "lowercase")]
217pub enum Op {
218 Rename,
219 Move,
220 Copy,
221}
222
223/// What a listing is ordered by ([`P_SORT`]). Folders always sort before
224/// files, so a size or date order does not scatter them through the listing.
225#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
226#[serde(rename_all = "lowercase")]
227pub enum SortKey {
228 #[default]
229 Name,
230 Size,
231 Modified,
232}
233
234impl SortKey {
235 pub fn as_str(self) -> &'static str {
236 match self {
237 SortKey::Name => "name",
238 SortKey::Size => "size",
239 SortKey::Modified => "modified",
240 }
241 }
242
243 pub fn parse(s: &str) -> Option<Self> {
244 match s {
245 "name" => Some(SortKey::Name),
246 "size" => Some(SortKey::Size),
247 "modified" => Some(SortKey::Modified),
248 _ => None,
249 }
250 }
251}
252
253// ---------------------------------------------------------------------------
254// Request bodies (client → server)
255// ---------------------------------------------------------------------------
256
257#[derive(Serialize, Deserialize)]
258pub struct Credentials {
259 pub name: String,
260 pub password: String,
261}
262
263/// Rename / move / copy (one body for all file mutations).
264#[derive(Serialize, Deserialize)]
265pub struct Mutation {
266 pub op: Op,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub new_name: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub dst_root_id: Option<i64>,
271 /// Destination directory, relative to `dst_root_id`.
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub dst: Option<String>,
274 #[serde(default)]
275 pub overwrite: bool,
276}
277
278/// A user folder: path relative to the server root + access mode.
279#[derive(Serialize, Deserialize)]
280pub struct Root {
281 /// Path relative to the server root; "." means the whole root.
282 pub path: String,
283 #[serde(default = "default_rw")]
284 pub mode: Mode,
285}
286
287fn default_rw() -> Mode {
288 Mode::Rw
289}
290
291#[derive(Serialize, Deserialize)]
292pub struct CreateUser {
293 pub name: String,
294 pub password: String,
295 #[serde(default)]
296 pub is_admin: bool,
297 #[serde(default)]
298 pub roots: Vec<Root>,
299}
300
301#[derive(Serialize, Deserialize)]
302pub struct UpdateUser {
303 /// Setting one is also the recovery path for a locked-out account: it
304 /// deletes every passkey and puts the account back on
305 /// [`AuthMode::Either`], leaving the new password as the one way in.
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub password: Option<String>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub is_admin: Option<bool>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub active: Option<bool>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub roots: Option<Vec<Root>>,
314}
315
316/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
317#[derive(Serialize, Deserialize, Clone)]
318pub struct Settings {
319 pub allow_writable_shares: bool,
320 /// Folders left out of every search, as paths relative to the server
321 /// root. A path covers everything beneath it.
322 #[serde(default)]
323 pub search_excludes: Vec<String>,
324}
325
326#[derive(Serialize, Deserialize)]
327pub struct CreateShare {
328 pub root_id: i64,
329 /// Item path relative to the root ("" or "." for the root itself).
330 pub path: String,
331 #[serde(default)]
332 pub writable: bool,
333 /// Absolute expiry as RFC 3339; absent = never.
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub expires_at: Option<String>,
336 /// Password the visitor must enter before the share opens; absent = none.
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub password: Option<String>,
339}
340
341/// POST `{SHARE}/{token}/unlock` — the password for a protected share.
342#[derive(Serialize, Deserialize)]
343pub struct UnlockShare {
344 pub password: String,
345}
346
347// ---------------------------------------------------------------------------
348// Responses (server → client)
349// ---------------------------------------------------------------------------
350
351/// What a listing entry actually is, decided by the server from the file's
352/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
353/// not from its name. Drives the icon and the preview the client offers.
354///
355/// Deliberately coarse: this answers "which viewer opens this", not "what
356/// exact format is it". Syntax highlighting still keys off the extension,
357/// because `.h` is C or C++ and no amount of sniffing decides that.
358#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
359#[serde(rename_all = "lowercase")]
360pub enum FileKind {
361 Dir,
362 Image,
363 Video,
364 Audio,
365 Pdf,
366 Archive,
367 /// Anything that decodes as text: source code, markup, config, plain text.
368 Text,
369 /// Recognized-but-not-viewable, or undecodable bytes.
370 Binary,
371}
372
373#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
374pub struct Entry {
375 pub name: String,
376 pub is_dir: bool,
377 pub size: u64,
378 /// RFC 3339 UTC modification time.
379 pub mtime: String,
380 /// Content-sniffed kind (see [`FileKind`]).
381 pub kind: FileKind,
382}
383
384/// One page of a folder listing.
385#[derive(Serialize, Deserialize)]
386pub struct FilesResp {
387 pub entries: Vec<Entry>,
388 /// Entries in the whole folder, across all pages.
389 pub total: usize,
390 /// Position of `entries[0]` in the sorted folder. An offset past the end
391 /// comes back as the start of the last page.
392 pub offset: usize,
393}
394
395/// Cap on entries in one listing page.
396pub const MAX_LIST_ENTRIES: usize = 10_000;
397
398/// One streamed search result. Each is sent as one SSE event
399/// (`data: <json>`), in the order found; the `done` event always ends the
400/// stream.
401#[derive(Serialize, Deserialize, Clone)]
402#[serde(tag = "type", rename_all = "snake_case")]
403pub enum SearchEvent {
404 /// A file or folder whose name matched (scope `name`/`both`).
405 File {
406 root_id: i64,
407 /// Path relative to the root, `/`-separated.
408 path: String,
409 size: u64,
410 is_dir: bool,
411 /// Sniffed the same way as a directory listing's, so the client can
412 /// pick an icon and a viewer without a second guess at the name.
413 kind: FileKind,
414 },
415 /// One matching line (scope `content`/`both`). `path` is relative to the
416 /// root; `text` is the matched line, truncated to a fixed length.
417 Match {
418 root_id: i64,
419 path: String,
420 line: u64,
421 text: String,
422 },
423 /// Stream finished. `stopped` is true when the client aborted before the
424 /// search completed.
425 Done {
426 stopped: bool,
427 /// Files examined (walked) before the stream ended.
428 scanned: usize,
429 /// Files skipped for content search (over the size cap).
430 skipped: usize,
431 elapsed_ms: u64,
432 },
433}
434
435#[derive(Serialize, Deserialize, Clone, PartialEq)]
436pub struct UserInfo {
437 pub id: i64,
438 pub name: String,
439 pub is_admin: bool,
440 /// Profile setting: single click opens entries (off = click selects,
441 /// double click opens).
442 pub single_click_open: bool,
443 /// Profile setting: show image and video thumbnails in the grid.
444 pub thumbnails: bool,
445 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
446 /// browser.
447 pub language: Option<String>,
448 /// Profile setting: the root the UI opens on page load and on the home
449 /// link. Always one of `Me::roots` (the server drops a stale id), or
450 /// None for the root picker.
451 pub default_root_id: Option<i64>,
452 /// What this account needs to sign in.
453 pub auth_mode: AuthMode,
454 /// Whether a password is set at all. False means passkeys only.
455 pub has_password: bool,
456}
457
458#[derive(Serialize, Deserialize, Clone)]
459pub struct RootInfo {
460 pub id: i64,
461 pub name: String,
462 pub path: String,
463 pub mode: Mode,
464}
465
466/// GET `{AUTH_ME}`.
467#[derive(Serialize, Deserialize, Clone)]
468pub struct Me {
469 /// True while no users exist yet (first-boot setup).
470 pub first_boot: bool,
471 /// None on first boot.
472 pub user: Option<UserInfo>,
473 pub roots: Vec<RootInfo>,
474 pub allow_writable_shares: bool,
475 /// Whether the server can make thumbnails at all (`--cache` is set).
476 /// The profile setting is only offered when this is true.
477 pub thumbnails_available: bool,
478 /// `--public-url`, if set. The UI builds share links from it instead of
479 /// the page origin.
480 pub public_url: Option<String>,
481}
482
483/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
484#[derive(Serialize, Deserialize, Clone)]
485pub struct ShareInfo {
486 /// Also the share's synthetic root id in file API calls.
487 pub id: i64,
488 pub token: String,
489 /// Display name (file/folder name, or the root's name for ".").
490 pub name: String,
491 pub is_file: bool,
492 pub writable: bool,
493 /// Path relative to the server root.
494 pub target: String,
495 /// RFC 3339 UTC creation time.
496 pub created_at: String,
497 /// RFC 3339 UTC expiry; None = never.
498 pub expires_at: Option<String>,
499 /// The file's kind for file shares (None for folder shares, and when
500 /// not sniffed — the public resolve endpoint fills it in).
501 pub kind: Option<FileKind>,
502 /// Whether the share asks for a password. Never the password itself.
503 pub has_password: bool,
504}
505
506/// One share plus who owns it: GET `{ADMIN_SHARES}`.
507///
508/// Admin-only: it carries the full [`ShareInfo::token`], and a token is access.
509/// Kept separate from [`ShareInfo`] because the public resolve route answers
510/// with a `ShareInfo` to anonymous visitors.
511#[derive(Serialize, Deserialize, Clone)]
512pub struct AdminShare {
513 #[serde(flatten)]
514 pub share: ShareInfo,
515 pub creator_id: i64,
516 pub creator_name: String,
517 /// Whether the creator's account can still sign in. Deactivating an account
518 /// does not revoke its shares, so `false` marks a live link its owner can no
519 /// longer manage.
520 pub creator_active: bool,
521}
522
523/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
524#[derive(Serialize, Deserialize, Clone)]
525pub struct AdminUser {
526 pub id: i64,
527 pub name: String,
528 pub is_admin: bool,
529 pub active: bool,
530 pub roots: Vec<RootInfo>,
531}
532
533/// Acknowledges a successful mutation. Carries nothing: the 2xx status is
534/// the acknowledgement, so the body is the empty object.
535#[derive(Serialize, Deserialize)]
536pub struct OkResp {}
537
538#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
539#[serde(rename_all = "lowercase")]
540pub enum PimCollectionKind {
541 Calendar,
542 Addressbook,
543}
544
545/// How a calendar or address book is lent. The serde names are also the
546/// values stored in `pim_shares.mode`.
547#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
548pub enum PimShareMode {
549 #[serde(rename = "ro")]
550 Ro,
551 /// Change members, but send no scheduling messages as the owner.
552 #[serde(rename = "rw")]
553 Rw,
554 /// Also invite and answer as the owner, named in SENT-BY.
555 #[serde(rename = "rw+schedule")]
556 RwSchedule,
557}
558
559impl PimShareMode {
560 pub fn as_str(self) -> &'static str {
561 match self {
562 PimShareMode::Ro => "ro",
563 PimShareMode::Rw => "rw",
564 PimShareMode::RwSchedule => "rw+schedule",
565 }
566 }
567
568 pub fn from_wire(s: &str) -> Option<Self> {
569 match s {
570 "ro" => Some(PimShareMode::Ro),
571 "rw" => Some(PimShareMode::Rw),
572 "rw+schedule" => Some(PimShareMode::RwSchedule),
573 _ => None,
574 }
575 }
576}
577
578/// One entry of `GET {PIM_COLLECTIONS}`.
579#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
580pub struct PimCollectionInfo {
581 /// `0` for the system address book and `-1` for the birthday calendar,
582 /// which the server generates.
583 pub id: i64,
584 pub kind: PimCollectionKind,
585 pub name: String,
586 /// The CalDAV or CardDAV URL, as seen by the signed-in user.
587 pub url: String,
588 pub owner: String,
589 /// `None` for an own collection, the loan's mode for a lent one.
590 pub mode: Option<PimShareMode>,
591 /// Generated by the server, so read-only.
592 #[serde(default)]
593 pub generated: bool,
594 #[serde(default)]
595 pub color: Option<String>,
596 #[serde(default)]
597 pub description: Option<String>,
598 /// Calendars: the component types it takes, e.g. `VEVENT`.
599 #[serde(default)]
600 pub components: Vec<String>,
601 /// Calendars: adds no busy time to scheduling.
602 #[serde(default)]
603 pub transparent: bool,
604 /// The calendar that receives invitations. It cannot be deleted.
605 #[serde(default)]
606 pub is_default: bool,
607 /// Own collections: how many accounts it is lent to.
608 #[serde(default)]
609 pub shares: usize,
610 /// Own collections: how many public feeds it has.
611 #[serde(default)]
612 pub links: usize,
613}
614
615/// `POST {PIM_COLLECTIONS}`.
616#[derive(Serialize, Deserialize, Clone, Debug)]
617pub struct CreatePimCollection {
618 pub kind: PimCollectionKind,
619 pub name: String,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub color: Option<String>,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub description: Option<String>,
624 /// Calendars: `VEVENT`, `VTODO`, `VJOURNAL`. Empty takes all three.
625 #[serde(default, skip_serializing_if = "Vec::is_empty")]
626 pub components: Vec<String>,
627}
628
629/// `PUT {PIM_COLLECTIONS}/{id}`: absent fields stay; an empty `color` or
630/// `description` removes it.
631#[derive(Serialize, Deserialize, Clone, Debug, Default)]
632pub struct UpdatePimCollection {
633 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub name: Option<String>,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub color: Option<String>,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub description: Option<String>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub transparent: Option<bool>,
641}
642
643/// One occurrence of an event, task or journal entry: `GET {PIM_INSTANCES}`.
644#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
645pub struct PimInstance {
646 pub collection_id: i64,
647 /// The object's resource name in the collection.
648 pub name: String,
649 pub uid: String,
650 /// The original start of a recurring instance (RFC 3339 UTC); `None`
651 /// for an object that does not recur.
652 pub recurrence_id: Option<String>,
653 /// RFC 3339 UTC. An all-day instance starts at midnight in `tz`.
654 pub start: String,
655 pub end: String,
656 pub all_day: bool,
657 /// `VEVENT`, `VTODO` or `VJOURNAL`.
658 pub component: String,
659 pub summary: Option<String>,
660 pub location: Option<String>,
661 /// `TENTATIVE`, `CONFIRMED`, `CANCELLED`, ...
662 pub status: Option<String>,
663 pub transparent: bool,
664 pub has_attendees: bool,
665 /// The calendar owner's PARTSTAT when they are an attendee.
666 pub partstat: Option<String>,
667 /// The organizer's name, else address.
668 pub organizer: Option<String>,
669}
670
671#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
672pub struct PimInstances {
673 pub instances: Vec<PimInstance>,
674 /// Not every instance fits: the range held too many.
675 pub truncated: bool,
676}
677
678#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
679pub struct PimPerson {
680 pub name: Option<String>,
681 /// The calendar user address, e.g. `mailto:...`.
682 pub address: String,
683}
684
685#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
686pub struct PimAttendee {
687 #[serde(flatten)]
688 pub person: PimPerson,
689 pub partstat: Option<String>,
690 pub role: Option<String>,
691 /// The calendar owner.
692 pub is_owner: bool,
693}
694
695#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
696pub struct PimEventDetail {
697 pub collection_id: i64,
698 pub name: String,
699 pub uid: String,
700 pub component: String,
701 pub summary: Option<String>,
702 pub description: Option<String>,
703 pub location: Option<String>,
704 pub url: Option<String>,
705 pub status: Option<String>,
706 pub transparent: bool,
707 pub all_day: bool,
708 pub categories: Vec<String>,
709 /// The RRULE of the series, e.g. `FREQ=WEEKLY;BYDAY=MO`.
710 pub rrule: Option<String>,
711 pub organizer: Option<PimPerson>,
712 pub attendees: Vec<PimAttendee>,
713 /// The signed-in user may change the object with a client.
714 pub can_edit: bool,
715 /// The calendar owner is an attendee and the signed-in user may answer
716 /// for them.
717 pub can_reply: bool,
718}
719
720#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
721pub struct PimLabeled {
722 /// `work`, `home`, a client's own label, ...
723 pub label: Option<String>,
724 pub value: String,
725}
726
727/// One entry of `GET {PIM_CONTACTS}`.
728#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
729pub struct PimContact {
730 pub collection_id: i64,
731 pub name: String,
732 pub full_name: String,
733 pub org: Option<String>,
734 pub email: Option<String>,
735 pub phone: Option<String>,
736 pub has_photo: bool,
737 pub is_group: bool,
738}
739
740#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
741pub struct PimContactDetail {
742 pub collection_id: i64,
743 pub name: String,
744 pub uid: Option<String>,
745 pub full_name: String,
746 pub org: Option<String>,
747 pub title: Option<String>,
748 pub emails: Vec<PimLabeled>,
749 pub phones: Vec<PimLabeled>,
750 /// One address per entry, its parts on separate lines.
751 pub addresses: Vec<PimLabeled>,
752 pub urls: Vec<PimLabeled>,
753 /// `1980-03-15`, or `--03-15` without a year.
754 pub birthday: Option<String>,
755 pub anniversary: Option<String>,
756 pub note: Option<String>,
757 pub is_group: bool,
758 /// A group's members, by name where the address book knows them.
759 pub members: Vec<String>,
760 /// `{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`.
761 pub photo_url: Option<String>,
762 pub can_edit: bool,
763}
764
765/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`.
766#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
767#[serde(tag = "type", rename_all = "lowercase")]
768pub enum PimObjectDetail {
769 Event(PimEventDetail),
770 Contact(PimContactDetail),
771}
772
773/// One entry of `GET {PIM_INVITATIONS}`: a series, or one instance of it
774/// when that instance was invited on its own.
775#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
776pub struct PimInvitation {
777 pub collection_id: i64,
778 pub name: String,
779 pub uid: String,
780 /// `None`: the whole series.
781 pub recurrence_id: Option<String>,
782 pub summary: Option<String>,
783 pub location: Option<String>,
784 pub organizer: Option<PimPerson>,
785 /// The next start, RFC 3339 UTC.
786 pub start: String,
787 pub end: String,
788 pub all_day: bool,
789 pub recurring: bool,
790}
791
792/// `POST {PIM_INVITATIONS}`: the calendar owner's answer. The server writes
793/// it into their copy and tells the organizer, as a client would.
794#[derive(Serialize, Deserialize, Clone, Debug)]
795pub struct PimReply {
796 pub collection_id: i64,
797 pub name: String,
798 /// Answer one instance only. As in [`PimInstance::recurrence_id`].
799 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub recurrence_id: Option<String>,
801 /// `ACCEPTED`, `TENTATIVE` or `DECLINED`.
802 pub partstat: String,
803 /// The IANA zone of the request that listed the instance.
804 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub tz: Option<String>,
806}
807
808/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`.
809#[derive(Serialize, Deserialize, Clone, Debug)]
810pub struct PimShareInfo {
811 pub user_id: i64,
812 pub user_name: String,
813 pub mode: PimShareMode,
814}
815
816/// `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`: lend to an account, or
817/// change the mode of an existing loan.
818#[derive(Serialize, Deserialize)]
819pub struct CreatePimShare {
820 pub user: String,
821 pub mode: PimShareMode,
822}
823
824/// One entry of `GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
825#[derive(Serialize, Deserialize, Clone, Debug)]
826pub struct PimLinkInfo {
827 pub id: i64,
828 /// `{FEED}/{token}` with the extension.
829 pub path: String,
830 pub busy_only: bool,
831 pub created_at: String,
832 pub expires_at: Option<String>,
833 pub has_password: bool,
834}
835
836/// One feed with its collection and owner: GET `{ADMIN_PIM_LINKS}`.
837#[derive(Serialize, Deserialize, Clone, Debug)]
838pub struct AdminPimLink {
839 #[serde(flatten)]
840 pub link: PimLinkInfo,
841 pub collection_id: i64,
842 pub collection_name: String,
843 pub kind: PimCollectionKind,
844 pub owner_id: i64,
845 pub owner_name: String,
846 /// Whether the owner can still sign in. A disabled owner's feeds stay live.
847 pub owner_active: bool,
848}
849
850/// `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
851#[derive(Serialize, Deserialize, Default)]
852pub struct CreatePimLink {
853 /// Calendars only: events without their details.
854 #[serde(default)]
855 pub busy_only: bool,
856 /// Absolute expiry as RFC 3339; absent = never.
857 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub expires_at: Option<String>,
859 /// Asked for with HTTP Basic; the user name is ignored.
860 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub password: Option<String>,
862}
863
864/// The answer to an import.
865#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
866pub struct PimImportResult {
867 pub created: usize,
868 pub updated: usize,
869 /// All skipped objects, also those beyond `skipped`.
870 pub skipped_total: usize,
871 /// The first skipped objects.
872 pub skipped: Vec<PimSkipped>,
873}
874
875#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
876pub struct PimSkipped {
877 pub uid: Option<String>,
878 /// The precondition a PUT of the object would fail, e.g.
879 /// `valid-calendar-object-resource`.
880 pub reason: String,
881}
882
883#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
884#[serde(rename_all = "lowercase")]
885pub enum RoomKind {
886 Room,
887 Resource,
888}
889
890/// A room or resource: `GET {ADMIN_ROOMS}`.
891#[derive(Serialize, Deserialize, Clone, Debug)]
892pub struct RoomInfo {
893 pub id: i64,
894 /// The URL segment. Fixed, since it is also the scheduling address.
895 pub name: String,
896 pub display_name: String,
897 pub kind: RoomKind,
898 /// The principal URL.
899 pub url: String,
900}
901
902/// `POST {ADMIN_ROOMS}`.
903#[derive(Serialize, Deserialize)]
904pub struct CreateRoom {
905 pub name: String,
906 /// Defaults to `name`.
907 pub display_name: Option<String>,
908 pub kind: RoomKind,
909}
910
911/// `PUT {ADMIN_ROOMS}/{id}`.
912#[derive(Serialize, Deserialize)]
913pub struct UpdateRoom {
914 pub display_name: String,
915}
916
917/// `POST ...?action=exists` body: upload targets relative to the request
918/// directory (may contain subfolders, like upload part names).
919#[derive(Serialize, Deserialize)]
920pub struct ExistsReq {
921 pub paths: Vec<String>,
922}
923
924/// One existing upload target.
925#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
926pub struct Existing {
927 pub path: String,
928 pub is_dir: bool,
929}
930
931/// `POST ...?action=exists` response: the subset of the requested paths
932/// that exist, in request order.
933#[derive(Serialize, Deserialize)]
934pub struct ExistsResp {
935 pub existing: Vec<Existing>,
936}
937
938/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
939#[derive(Serialize, Deserialize)]
940pub struct SaveResp {
941 pub mtime: i64,
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 /// The serde spellings are the database values too, so they are pinned.
949 #[test]
950 fn mode_wire_format_is_rw_ro() {
951 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
952 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
953 for m in [Mode::Rw, Mode::Ro] {
954 let s = serde_json::to_string(&m).unwrap();
955 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
956 // `as_str`/`from_wire` must agree with serde.
957 assert_eq!(s, format!("\"{}\"", m.as_str()));
958 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
959 }
960 assert_eq!(Mode::from_wire("both"), None);
961 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
962 assert!(Mode::Rw.is_writable());
963 assert!(!Mode::Ro.is_writable());
964 }
965
966 #[test]
967 fn pim_share_mode_wire_format() {
968 for m in [PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule] {
969 let s = serde_json::to_string(&m).unwrap();
970 assert_eq!(s, format!("\"{}\"", m.as_str()));
971 assert_eq!(serde_json::from_str::<PimShareMode>(&s).unwrap(), m);
972 assert_eq!(PimShareMode::from_wire(m.as_str()), Some(m));
973 }
974 assert_eq!(PimShareMode::RwSchedule.as_str(), "rw+schedule");
975 }
976
977 #[test]
978 fn op_wire_format() {
979 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
980 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
981 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
982 for op in [Op::Rename, Op::Move, Op::Copy] {
983 let s = serde_json::to_string(&op).unwrap();
984 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
985 }
986 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
987 }
988
989 #[test]
990 fn mutation_round_trip_skips_absent_fields() {
991 let m = Mutation {
992 op: Op::Move,
993 new_name: None,
994 dst_root_id: Some(3),
995 dst: Some("docs".into()),
996 overwrite: true,
997 };
998 let s = serde_json::to_string(&m).unwrap();
999 assert!(!s.contains("new_name"));
1000 let back: Mutation = serde_json::from_str(&s).unwrap();
1001 assert_eq!(back.dst_root_id, Some(3));
1002 assert_eq!(back.op, Op::Move);
1003 }
1004
1005 #[test]
1006 fn mutation_defaults_missing_fields() {
1007 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
1008 assert!(!m.overwrite);
1009 assert_eq!(m.dst, None);
1010 }
1011
1012 #[test]
1013 fn root_defaults_mode_to_rw() {
1014 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
1015 assert_eq!(r.mode, Mode::Rw);
1016 }
1017
1018 #[test]
1019 fn me_round_trip() {
1020 let me = Me {
1021 first_boot: false,
1022 user: Some(UserInfo {
1023 id: 1,
1024 name: "admin".into(),
1025 is_admin: true,
1026 single_click_open: false,
1027 thumbnails: true,
1028 language: None,
1029 default_root_id: None,
1030 auth_mode: AuthMode::Either,
1031 has_password: true,
1032 }),
1033 roots: vec![RootInfo {
1034 id: 1,
1035 name: "root".into(),
1036 path: ".".into(),
1037 mode: Mode::Rw,
1038 }],
1039 allow_writable_shares: false,
1040 thumbnails_available: true,
1041 public_url: None,
1042 };
1043 let s = serde_json::to_string(&me).unwrap();
1044 let back: Me = serde_json::from_str(&s).unwrap();
1045 assert_eq!(back.roots.len(), 1);
1046 }
1047}
1048
1049// ---------------------------------------------------------------------------
1050// Sign-in methods: password, passkeys, and how they combine
1051// ---------------------------------------------------------------------------
1052
1053/// What an account needs to sign in.
1054///
1055/// Not a "2FA on/off" flag: [`AuthMode::Either`] with no password is a
1056/// passkey-only account, which is still two factors when the authenticator
1057/// does user verification (the server always asks for it).
1058#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
1059#[serde(rename_all = "lowercase")]
1060pub enum AuthMode {
1061 /// Password *or* passkey. Either one alone signs the user in.
1062 #[default]
1063 Either,
1064 /// Password *and* passkey. Both legs must pass, in either order.
1065 Both,
1066}
1067
1068impl AuthMode {
1069 pub fn as_str(self) -> &'static str {
1070 match self {
1071 AuthMode::Either => "either",
1072 AuthMode::Both => "both",
1073 }
1074 }
1075
1076 pub fn from_wire(s: &str) -> Option<Self> {
1077 match s {
1078 "either" => Some(AuthMode::Either),
1079 "both" => Some(AuthMode::Both),
1080 _ => None,
1081 }
1082 }
1083}
1084
1085/// One registered passkey, as shown in profile settings. Never carries key
1086/// material.
1087#[derive(Serialize, Deserialize, Clone)]
1088pub struct PasskeyInfo {
1089 pub id: i64,
1090 /// User-chosen label ("YubiKey", "Work laptop").
1091 pub name: String,
1092 /// RFC 3339 UTC.
1093 pub created_at: String,
1094 pub last_used_at: Option<String>,
1095 /// Whether the browser reported this credential as discoverable, so it
1096 /// can sign in without the account name. `None` when the browser did not
1097 /// say — the `credProps` extension is optional and unsigned, so absence
1098 /// means "unknown", never "no".
1099 pub discoverable: Option<bool>,
1100}
1101
1102/// One app password, as shown in profile settings.
1103///
1104/// WebDAV-only: it never signs in to the web UI. Never carries the secret,
1105/// which exists only in [`NewAppPassword`].
1106#[derive(Serialize, Deserialize, Clone)]
1107pub struct AppPasswordInfo {
1108 pub id: i64,
1109 /// User-chosen label ("Laptop mount", "phone").
1110 pub name: String,
1111 /// RFC 3339 UTC.
1112 pub created_at: String,
1113 /// Only tracked to the hour.
1114 pub last_used_at: Option<String>,
1115}
1116
1117/// `POST {AUTH_APP_PASSWORDS}`.
1118#[derive(Serialize, Deserialize)]
1119pub struct CreateAppPassword {
1120 pub name: String,
1121}
1122
1123/// The answer to `POST {AUTH_APP_PASSWORDS}`.
1124///
1125/// The only time `secret` is readable. The server keeps a hash of it.
1126#[derive(Serialize, Deserialize)]
1127pub struct NewAppPassword {
1128 #[serde(flatten)]
1129 pub info: AppPasswordInfo,
1130 pub secret: String,
1131}
1132
1133/// `POST {AUTH_PASSWORD}` — set or change the password.
1134///
1135/// No current password to confirm: a passkey-only account has none to give.
1136/// The session is the gate, and the server drops the account's other
1137/// sessions on every change.
1138#[derive(Serialize, Deserialize)]
1139pub struct ChangePassword {
1140 pub new_password: String,
1141}
1142
1143/// `PUT {AUTH_MODE}`.
1144#[derive(Serialize, Deserialize)]
1145pub struct SetAuthMode {
1146 pub mode: AuthMode,
1147}
1148
1149/// A WebAuthn challenge on its way to the browser.
1150///
1151/// `options` is the raw JSON the browser's `parseCreationOptionsFromJSON` /
1152/// `parseRequestOptionsFromJSON` expects, carried as a string rather than a
1153/// nested object. Neither side has to re-parse it: the server serializes the
1154/// `webauthn-rs` type straight into it, and the client hands it to
1155/// `JSON.parse` in the browser shim.
1156#[derive(Serialize, Deserialize)]
1157pub struct PasskeyChallenge {
1158 /// Opaque handle for the server-side ceremony state. Echoed back on
1159 /// finish. Not a credential, and useless on its own.
1160 pub state_id: String,
1161 pub options: String,
1162}
1163
1164/// `POST {AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
1165#[derive(Serialize, Deserialize)]
1166pub struct PasskeyRegisterFinish {
1167 pub state_id: String,
1168 /// Label for the new passkey.
1169 pub name: String,
1170 /// The browser's `PublicKeyCredential.toJSON()` output, verbatim.
1171 pub credential: String,
1172}
1173
1174/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
1175#[derive(Serialize, Deserialize)]
1176pub struct PasskeyLoginBegin {
1177 /// Account name, when the user typed one. Without it the server issues a
1178 /// discoverable challenge, which only finds passkeys the authenticator
1179 /// stores itself.
1180 #[serde(default, skip_serializing_if = "Option::is_none")]
1181 pub name: Option<String>,
1182 /// Ask for a conditional-mediation (autofill) challenge instead of a
1183 /// modal one.
1184 #[serde(default)]
1185 pub conditional: bool,
1186}
1187
1188/// `POST {AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
1189#[derive(Serialize, Deserialize)]
1190pub struct PasskeyLoginFinish {
1191 pub state_id: String,
1192 pub credential: String,
1193}
1194
1195/// `POST {AUTH_LOGIN}` — the password leg of a sign-in.
1196#[derive(Serialize, Deserialize)]
1197pub struct LoginReq {
1198 /// Omitted only when `state_id` names a half-finished sign-in, which
1199 /// already knows who the user is.
1200 #[serde(default, skip_serializing_if = "Option::is_none")]
1201 pub name: Option<String>,
1202 pub password: String,
1203 /// Handle from a passkey leg that still needs a password (an
1204 /// [`AuthMode::Both`] account signing in passkey-first).
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1206 pub state_id: Option<String>,
1207}
1208
1209/// The answer to either sign-in leg.
1210///
1211/// Exactly one of the three shapes: signed in, needs a passkey next, or needs
1212/// a password next. The two "needs" cases are how [`AuthMode::Both`] works,
1213/// and which one appears depends only on which leg the user started with.
1214#[derive(Serialize, Deserialize, Default)]
1215pub struct LoginResp {
1216 /// True when the session cookie is set and the user is in.
1217 pub ok: bool,
1218 /// Present when this leg passed but a passkey is still required.
1219 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub passkey_challenge: Option<PasskeyChallenge>,
1221 /// Present when this leg passed but the password is still required.
1222 /// Carries the account name, so the form can show whose password it
1223 /// wants, and the handle to send back with it.
1224 #[serde(default, skip_serializing_if = "Option::is_none")]
1225 pub password_required: Option<PasswordStep>,
1226}
1227
1228#[derive(Serialize, Deserialize, Clone)]
1229pub struct PasswordStep {
1230 pub name: String,
1231 pub state_id: String,
1232}
1233