Skip to main content

dioxus_clerk/core/
state.rs

1//! Provider runtime auth state plus typed mirrors of clerk-js User/Session shapes.
2
3use super::{AuthState, AuthStatus};
4use crate::ssr::{InitialAuthSnapshot, InitialAuthStatus};
5use serde::{Deserialize, Serialize};
6use std::borrow::Cow;
7
8/// Reactive auth state machine held by `ClerkProvider`.
9#[doc(hidden)]
10#[derive(Debug, Clone, PartialEq)]
11pub(crate) struct AuthRuntimeState {
12    status: AuthRuntimeStateKind,
13    is_loaded: bool,
14}
15
16#[derive(Debug, Clone, PartialEq)]
17#[cfg_attr(not(clerk_client), allow(dead_code))]
18enum AuthRuntimeStateKind {
19    /// Clerk has not resolved yet; there is no reliable auth answer.
20    Loading,
21    /// Auth resolved; no active session.
22    SignedOut,
23    /// Server verified a session during SSR, but clerk-js has not supplied
24    /// full `User` and `Session` objects on the client yet.
25    SignedInSnapshot {
26        /// Clerk user id from verified server auth.
27        user_id: String,
28        /// Active session id from verified server auth, if present.
29        session_id: Option<String>,
30        /// Active organization id from verified server auth, if present.
31        org_id: Option<String>,
32        /// Active organization slug from verified server auth, if present.
33        org_slug: Option<String>,
34        /// Organization role from verified server auth, if present.
35        org_role: Option<String>,
36        /// Organization permissions from verified server auth.
37        org_permissions: Vec<String>,
38    },
39    /// Clerk JS loaded; an active session exists with full client details.
40    SignedIn {
41        /// Authenticated user.
42        user: User,
43        /// Active session.
44        session: Session,
45        /// Active organization id if it came from verified server auth.
46        org_id: Option<String>,
47        /// Active organization slug if it came from verified server auth.
48        org_slug: Option<String>,
49        /// Organization role if it came from verified server auth.
50        org_role: Option<String>,
51        /// Organization permissions if they came from verified server auth.
52        org_permissions: Vec<String>,
53    },
54    /// Clerk JS loaded a signed-in session that is not yet fully active: it has
55    /// pending after-auth tasks (`status == "pending"`). Treated as signed-out
56    /// for rendering gates (clerk-js `treatPendingAsSignedOut` default), but the
57    /// session and its `current_task` are exposed for after-auth routing.
58    Pending {
59        /// Authenticated user.
60        user: User,
61        /// Pending session, carrying `current_task`/`tasks`.
62        session: Session,
63    },
64}
65
66/// A clerk-js observation that can transition application-visible auth state.
67// One short-lived value per clerk-js event that is consumed immediately;
68// boxing the signed-in payload would trade a transient stack-size difference
69// for a per-event allocation.
70#[allow(clippy::large_enum_variant)]
71#[derive(Debug, Clone, PartialEq)]
72#[cfg_attr(not(clerk_client), allow(dead_code))]
73pub(crate) enum AuthObservation {
74    /// Clerk has not supplied a reliable auth answer.
75    Loading,
76    /// Clerk resolved with no active session.
77    SignedOut,
78    /// Clerk resolved with full client-side user and session details.
79    SignedIn {
80        /// Authenticated user.
81        user: User,
82        /// Active session.
83        session: Session,
84    },
85    /// Clerk resolved a signed-in session with pending after-auth tasks
86    /// (`status == "pending"`). Treated as signed-out for gates, but the
87    /// session's `current_task` is exposed for after-auth routing.
88    Pending {
89        /// Authenticated user.
90        user: User,
91        /// Pending session, carrying `current_task`/`tasks`.
92        session: Session,
93    },
94}
95
96impl Default for AuthRuntimeState {
97    fn default() -> Self {
98        Self::loading()
99    }
100}
101
102#[cfg_attr(not(clerk_client), allow(dead_code))]
103impl AuthRuntimeState {
104    /// Initial unknown state before clerk-js has loaded.
105    pub fn loading() -> Self {
106        Self::with_status(AuthRuntimeStateKind::Loading, false)
107    }
108
109    fn with_status(status: AuthRuntimeStateKind, is_loaded: bool) -> Self {
110        Self { status, is_loaded }
111    }
112
113    fn signed_out_with_loaded(is_loaded: bool) -> Self {
114        Self::with_status(AuthRuntimeStateKind::SignedOut, is_loaded)
115    }
116
117    fn with_loaded(mut self, is_loaded: bool) -> Self {
118        self.is_loaded = is_loaded;
119        self
120    }
121
122    /// Interpret an SSR initial auth snapshot as initial client Auth state.
123    ///
124    /// Only a server-verified signed-out snapshot seeds a resolved signed-out
125    /// state; an unverified snapshot stays loading so a render that never
126    /// checked the session cannot flash signed-out UI at a signed-in user.
127    /// A signed-in snapshot with a missing or empty user id is malformed and
128    /// stays loading too: an empty user id cannot represent a signed-in
129    /// session, matching [`AuthState::signed_in`].
130    pub fn from_initial_auth_snapshot(snapshot: &InitialAuthSnapshot) -> Self {
131        match snapshot.status {
132            InitialAuthStatus::Unverified => return Self::loading(),
133            InitialAuthStatus::SignedOut => return Self::signed_out_with_loaded(false),
134            InitialAuthStatus::SignedIn => {}
135        }
136
137        let Some(user_id) = snapshot.user_id.clone().filter(|id| !id.is_empty()) else {
138            return Self::loading();
139        };
140
141        Self::with_status(
142            AuthRuntimeStateKind::SignedInSnapshot {
143                user_id,
144                session_id: snapshot.session_id.clone(),
145                org_id: snapshot.org_id.clone(),
146                org_slug: snapshot.org_slug.clone(),
147                org_role: snapshot.org_role.clone(),
148                org_permissions: snapshot.org_permissions.clone(),
149            },
150            false,
151        )
152    }
153
154    /// Build full signed-in state from clerk-js details while preserving
155    /// verified organization claims across same-session updates.
156    #[cfg(test)]
157    pub fn from_js_session(user: User, session: Session, previous: &Self) -> Self {
158        Self::from_js_session_with_loaded(user, session, previous, true)
159    }
160
161    /// Whether verified org claims from `previous` may be carried into a fresh
162    /// clerk-js observation. The org must match too: clerk-js switches the
163    /// active organization (`setActive({ organization })`) without changing
164    /// the session id, and stale role/permission claims must not keep
165    /// satisfying org gates for the wrong organization.
166    fn matches_user_session_org(
167        previous_user_id: &str,
168        previous_session_id: Option<&str>,
169        previous_org_id: Option<&str>,
170        user: &User,
171        session: &Session,
172    ) -> bool {
173        previous_user_id == user.id
174            && previous_session_id == Some(session.id.as_str())
175            && previous_org_id == session.last_active_organization_id.as_deref()
176    }
177
178    fn from_js_session_with_loaded(
179        user: User,
180        session: Session,
181        previous: &Self,
182        is_loaded: bool,
183    ) -> Self {
184        let (org_id, org_slug, org_role, org_permissions) = match &previous.status {
185            AuthRuntimeStateKind::SignedInSnapshot {
186                user_id,
187                session_id,
188                org_id,
189                org_slug,
190                org_role,
191                org_permissions,
192            } if Self::matches_user_session_org(
193                user_id,
194                session_id.as_deref(),
195                org_id.as_deref(),
196                &user,
197                &session,
198            ) =>
199            {
200                (
201                    org_id.clone(),
202                    org_slug.clone(),
203                    org_role.clone(),
204                    org_permissions.clone(),
205                )
206            }
207            AuthRuntimeStateKind::SignedIn {
208                user: previous_user,
209                session: previous_session,
210                org_id,
211                org_slug,
212                org_role,
213                org_permissions,
214            } if Self::matches_user_session_org(
215                &previous_user.id,
216                Some(previous_session.id.as_str()),
217                org_id.as_deref(),
218                &user,
219                &session,
220            ) =>
221            {
222                (
223                    org_id.clone(),
224                    org_slug.clone(),
225                    org_role.clone(),
226                    org_permissions.clone(),
227                )
228            }
229            _ => (None, None, None, vec![]),
230        };
231        Self::with_status(
232            AuthRuntimeStateKind::SignedIn {
233                user,
234                session,
235                org_id,
236                org_slug,
237                org_role,
238                org_permissions,
239            },
240            is_loaded,
241        )
242    }
243
244    /// Apply a clerk-js observation while preserving current loadedness.
245    pub fn apply_observation(&self, observation: AuthObservation) -> Self {
246        match observation {
247            // A transient interim event must not flash loading UI at a user who
248            // already has a resolved signed-in or pending session.
249            AuthObservation::Loading if self.is_signed_in() || self.is_pending() => self.clone(),
250            AuthObservation::Loading => {
251                Self::with_status(AuthRuntimeStateKind::Loading, self.is_loaded)
252            }
253            AuthObservation::SignedOut => Self::signed_out_with_loaded(self.is_loaded),
254            AuthObservation::SignedIn { user, session } => {
255                Self::from_js_session_with_loaded(user, session, self, self.is_loaded)
256            }
257            AuthObservation::Pending { user, session } => Self::with_status(
258                AuthRuntimeStateKind::Pending { user, session },
259                self.is_loaded,
260            ),
261        }
262    }
263
264    /// Apply an observation produced after `Clerk.load()` has completed.
265    pub fn apply_loaded_observation(&self, observation: AuthObservation) -> Self {
266        self.apply_observation(observation).with_loaded(true)
267    }
268
269    /// Convert the current runtime state into app-visible auth state.
270    pub fn to_state(&self) -> AuthState {
271        match &self.status {
272            AuthRuntimeStateKind::Loading => AuthState {
273                status: AuthStatus::Loading,
274                is_loaded: self.is_loaded,
275                ..AuthState::signed_out()
276            },
277            AuthRuntimeStateKind::SignedOut => AuthState {
278                status: AuthStatus::SignedOut,
279                is_loaded: self.is_loaded,
280                ..AuthState::signed_out()
281            },
282            AuthRuntimeStateKind::SignedInSnapshot {
283                user_id,
284                session_id,
285                org_id,
286                org_slug,
287                org_role,
288                org_permissions,
289            } => AuthState {
290                status: AuthStatus::SignedIn,
291                is_loaded: self.is_loaded,
292                user_id: Some(user_id.clone()),
293                session_id: session_id.clone(),
294                org_id: org_id.clone(),
295                org_slug: org_slug.clone(),
296                org_role: org_role.clone(),
297                org_permissions: org_permissions.clone(),
298            },
299            AuthRuntimeStateKind::SignedIn {
300                user,
301                session,
302                org_id,
303                org_slug,
304                org_role,
305                org_permissions,
306            } => AuthState {
307                status: AuthStatus::SignedIn,
308                is_loaded: self.is_loaded,
309                user_id: Some(user.id.clone()),
310                session_id: Some(session.id.clone()),
311                org_id: org_id.clone(),
312                org_slug: org_slug.clone(),
313                org_role: org_role.clone(),
314                org_permissions: org_permissions.clone(),
315            },
316            // A pending session is treated as signed-out for the app-visible
317            // auth answer; the pending session itself is read through
318            // `session()` / `use_session()`, not the flat auth state.
319            AuthRuntimeStateKind::Pending { .. } => AuthState {
320                status: AuthStatus::SignedOut,
321                is_loaded: self.is_loaded,
322                ..AuthState::signed_out()
323            },
324        }
325    }
326
327    /// Whether clerk-js has completed `Clerk.load()` for this auth state.
328    pub fn is_loaded(&self) -> bool {
329        self.is_loaded
330    }
331
332    /// True when the state represents either server-snapshot or full JS
333    /// signed-in knowledge. A pending session is not signed-in: it is treated
334    /// as signed-out until its after-auth tasks resolve.
335    pub fn is_signed_in(&self) -> bool {
336        matches!(
337            &self.status,
338            AuthRuntimeStateKind::SignedInSnapshot { .. } | AuthRuntimeStateKind::SignedIn { .. }
339        )
340    }
341
342    /// True when clerk-js reports a signed-in session with pending after-auth
343    /// tasks. Such a session gates as signed-out but is exposed for routing.
344    pub fn is_pending(&self) -> bool {
345        matches!(&self.status, AuthRuntimeStateKind::Pending { .. })
346    }
347
348    /// Apply `treat_pending_as_signed_out` to produce the effective state a
349    /// reader sees, mirroring clerk-js `resolveAuthState`.
350    ///
351    /// With the flag on (clerk-js's default), a pending session is left as-is
352    /// and every gate reads it as signed-out. With the flag off, the pending
353    /// session is read as its underlying signed-in state, so `SignedIn` /
354    /// `Protect` render and `use_auth().is_signed_in()` is true. Non-pending
355    /// states, and the flag-on case, borrow `self` without cloning.
356    ///
357    /// Role/permission gates still fail closed for a resolved pending session:
358    /// it carries no server-verified org claims (its org id comes only from the
359    /// live clerk-js session), matching the "gates fail closed without claims"
360    /// contract of [`AuthRuntimeState::allows_signed_in_gate`].
361    pub fn resolve_pending(&self, treat_pending_as_signed_out: bool) -> Cow<'_, Self> {
362        match &self.status {
363            AuthRuntimeStateKind::Pending { user, session } if !treat_pending_as_signed_out => {
364                Cow::Owned(Self::with_status(
365                    AuthRuntimeStateKind::SignedIn {
366                        org_id: session.last_active_organization_id.clone(),
367                        org_slug: None,
368                        org_role: None,
369                        org_permissions: Vec::new(),
370                        user: user.clone(),
371                        session: session.clone(),
372                    },
373                    self.is_loaded,
374                ))
375            }
376            _ => Cow::Borrowed(self),
377        }
378    }
379
380    /// Whether `SignedIn` rendering should be shown for this state.
381    pub fn should_render_signed_in(&self) -> bool {
382        self.is_signed_in()
383    }
384
385    /// Whether `SignedOut` rendering should be shown for this state. A pending
386    /// session renders signed-out, matching clerk-js's `treatPendingAsSignedOut`
387    /// default.
388    pub fn should_render_signed_out(&self) -> bool {
389        matches!(
390            &self.status,
391            AuthRuntimeStateKind::SignedOut | AuthRuntimeStateKind::Pending { .. }
392        )
393    }
394
395    /// Full clerk-js user details, available only after browser hydration.
396    pub fn user(&self) -> Option<&User> {
397        match &self.status {
398            AuthRuntimeStateKind::SignedIn { user, .. }
399            | AuthRuntimeStateKind::Pending { user, .. } => Some(user),
400            AuthRuntimeStateKind::Loading
401            | AuthRuntimeStateKind::SignedOut
402            | AuthRuntimeStateKind::SignedInSnapshot { .. } => None,
403        }
404    }
405
406    /// Full clerk-js session details, available only after browser hydration.
407    /// This includes a pending session, so callers can read its `current_task`
408    /// for after-auth routing.
409    pub fn session(&self) -> Option<&Session> {
410        match &self.status {
411            AuthRuntimeStateKind::SignedIn { session, .. }
412            | AuthRuntimeStateKind::Pending { session, .. } => Some(session),
413            AuthRuntimeStateKind::Loading
414            | AuthRuntimeStateKind::SignedOut
415            | AuthRuntimeStateKind::SignedInSnapshot { .. } => None,
416        }
417    }
418
419    /// Whether signed-in gating should render for this state and gate request.
420    ///
421    /// Matching Clerk React, `permission` takes precedence when both gates are
422    /// requested; `role` is ignored in that case.
423    pub fn allows_signed_in_gate(&self, role: Option<&str>, permission: Option<&str>) -> bool {
424        if !self.should_render_signed_in() {
425            return false;
426        }
427
428        let role = if permission.is_some() { None } else { role };
429
430        if role.is_none() && permission.is_none() {
431            return true;
432        }
433
434        let (org_role, org_permissions) = match &self.status {
435            AuthRuntimeStateKind::SignedInSnapshot {
436                org_role,
437                org_permissions,
438                ..
439            }
440            | AuthRuntimeStateKind::SignedIn {
441                org_role,
442                org_permissions,
443                ..
444            } => (org_role.as_deref(), org_permissions.as_slice()),
445            // Unreachable: `should_render_signed_in()` already returned false
446            // for these, but the match must stay exhaustive.
447            AuthRuntimeStateKind::Loading
448            | AuthRuntimeStateKind::SignedOut
449            | AuthRuntimeStateKind::Pending { .. } => (None, &[][..]),
450        };
451        let role_allowed = match role {
452            Some(required) => org_role == Some(required),
453            None => true,
454        };
455        let permission_allowed = match permission {
456            Some(required) => org_permissions.iter().any(|actual| actual == required),
457            None => true,
458        };
459
460        role_allowed && permission_allowed
461    }
462}
463
464/// Typed mirror of the fields this crate reads from a Clerk JS `User`. Unknown
465/// fields are ignored on deserialize. Use `dioxus_clerk::use_clerk()` for
466/// supported browser actions, or application JS for fields not mirrored here.
467///
468/// Construct values with [`User::new`] and set optional fields directly; the
469/// struct is `#[non_exhaustive]` so new clerk-js fields can be mirrored
470/// without a breaking release.
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472#[non_exhaustive]
473pub struct User {
474    /// Clerk user id, e.g. `user_2abc`.
475    pub id: String,
476    /// First name.
477    #[serde(default, alias = "firstName")]
478    pub first_name: Option<String>,
479    /// Last name.
480    #[serde(default, alias = "lastName")]
481    pub last_name: Option<String>,
482    /// Primary email address, serialized string-form.
483    ///
484    /// Deserialization also accepts clerk-js's object form
485    /// (`{ "emailAddress": "..." }`), extracting the address string.
486    #[serde(
487        default,
488        alias = "primaryEmailAddress",
489        deserialize_with = "deserialize_primary_email_address"
490    )]
491    pub primary_email_address: Option<String>,
492    /// Avatar URL.
493    #[serde(default, alias = "imageUrl")]
494    pub image_url: Option<String>,
495}
496
497impl User {
498    /// Creates a user with the given Clerk user id and all optional fields
499    /// unset. Set remaining fields directly.
500    pub fn new(id: impl Into<String>) -> Self {
501        Self {
502            id: id.into(),
503            first_name: None,
504            last_name: None,
505            primary_email_address: None,
506            image_url: None,
507        }
508    }
509}
510
511/// Extract the address string from clerk-js's `primaryEmailAddress`, which is
512/// either an already-flattened string or an `EmailAddress` object.
513fn deserialize_primary_email_address<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
514where
515    D: serde::Deserializer<'de>,
516{
517    #[derive(Deserialize)]
518    #[serde(untagged)]
519    enum PrimaryEmailAddress {
520        Address(String),
521        Object(EmailAddressObject),
522    }
523
524    #[derive(Deserialize)]
525    struct EmailAddressObject {
526        #[serde(default, alias = "emailAddress")]
527        email_address: Option<String>,
528    }
529
530    Ok(
531        match Option::<PrimaryEmailAddress>::deserialize(deserializer)? {
532            None => None,
533            Some(PrimaryEmailAddress::Address(address)) => Some(address),
534            Some(PrimaryEmailAddress::Object(object)) => object.email_address,
535        },
536    )
537}
538
539/// Session status as reported by clerk-js.
540///
541/// Serializes as the raw clerk-js status string. The enum is
542/// `#[non_exhaustive]`; statuses this crate has not named yet round-trip
543/// through [`SessionStatus::Other`].
544#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
545#[serde(from = "String", into = "String")]
546#[non_exhaustive]
547pub enum SessionStatus {
548    /// The session is valid and the user is signed in.
549    Active,
550    /// The user abandoned the auth flow before the session became active.
551    Abandoned,
552    /// The session ended, for example by signing out of it.
553    Ended,
554    /// The session expired.
555    Expired,
556    /// The session requires additional steps (for example MFA) before it is
557    /// active.
558    Pending,
559    /// The session was removed.
560    Removed,
561    /// The session was replaced by a newer session.
562    Replaced,
563    /// The session was revoked.
564    Revoked,
565    /// A status string this crate has not named yet.
566    ///
567    /// Only produced by the `From<&str>`/`From<String>`/`FromStr` conversions,
568    /// which canonicalize known strings to their named variants first. The
569    /// payload is an [`OtherStatus`] with no public constructor, so an `Other`
570    /// can never alias a named variant (e.g. hold `"active"`): reads through
571    /// [`SessionStatus::as_str`] and comparisons therefore stay consistent.
572    Other(OtherStatus),
573}
574
575/// A clerk-js session status string with no named [`SessionStatus`] variant.
576///
577/// Obtained by matching on [`SessionStatus::Other`]; read the raw string with
578/// [`OtherStatus::as_str`]. It has no public constructor: values are produced
579/// only by [`SessionStatus`]'s `From`/`FromStr` conversions, which canonicalize
580/// known strings first, so an `OtherStatus` never holds a value a named variant
581/// would represent.
582#[derive(Debug, Clone, PartialEq, Eq, Hash)]
583pub struct OtherStatus(String);
584
585impl OtherStatus {
586    /// The raw clerk-js status string.
587    pub fn as_str(&self) -> &str {
588        &self.0
589    }
590}
591
592impl std::fmt::Display for OtherStatus {
593    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594        f.write_str(&self.0)
595    }
596}
597
598impl SessionStatus {
599    /// The raw clerk-js status string.
600    pub fn as_str(&self) -> &str {
601        match self {
602            Self::Active => "active",
603            Self::Abandoned => "abandoned",
604            Self::Ended => "ended",
605            Self::Expired => "expired",
606            Self::Pending => "pending",
607            Self::Removed => "removed",
608            Self::Replaced => "replaced",
609            Self::Revoked => "revoked",
610            Self::Other(status) => status.as_str(),
611        }
612    }
613}
614
615impl SessionStatus {
616    fn from_known(status: &str) -> Option<Self> {
617        Some(match status {
618            "active" => Self::Active,
619            "abandoned" => Self::Abandoned,
620            "ended" => Self::Ended,
621            "expired" => Self::Expired,
622            "pending" => Self::Pending,
623            "removed" => Self::Removed,
624            "replaced" => Self::Replaced,
625            "revoked" => Self::Revoked,
626            _ => return None,
627        })
628    }
629}
630
631impl From<&str> for SessionStatus {
632    fn from(status: &str) -> Self {
633        Self::from_known(status).unwrap_or_else(|| Self::Other(OtherStatus(status.to_owned())))
634    }
635}
636
637impl From<String> for SessionStatus {
638    fn from(status: String) -> Self {
639        // Move the owned buffer into `Other` instead of re-allocating it.
640        Self::from_known(&status).unwrap_or(Self::Other(OtherStatus(status)))
641    }
642}
643
644impl From<SessionStatus> for String {
645    fn from(status: SessionStatus) -> Self {
646        status.as_str().to_owned()
647    }
648}
649
650impl std::str::FromStr for SessionStatus {
651    type Err = std::convert::Infallible;
652
653    fn from_str(status: &str) -> Result<Self, Self::Err> {
654        Ok(Self::from(status))
655    }
656}
657
658impl std::fmt::Display for SessionStatus {
659    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
660        f.write_str(self.as_str())
661    }
662}
663
664/// A clerk-js v6 post-authentication session task the user must complete
665/// before the session becomes fully active, such as MFA enrollment or
666/// organization selection.
667///
668/// The struct is `#[non_exhaustive]`; construct values with [`SessionTask::new`]
669/// so new clerk-js task fields can be mirrored without a breaking release.
670#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
671#[non_exhaustive]
672pub struct SessionTask {
673    /// The task identifier, as reported by clerk-js.
674    pub key: SessionTaskKey,
675}
676
677impl SessionTask {
678    /// Creates a session task for the given key.
679    pub fn new(key: impl Into<SessionTaskKey>) -> Self {
680        Self { key: key.into() }
681    }
682}
683
684/// A clerk-js v6 session task key.
685///
686/// Serializes as the raw clerk-js task-key string. The enum is
687/// `#[non_exhaustive]`; keys this crate has not named yet round-trip through
688/// [`SessionTaskKey::Other`], mirroring [`SessionStatus`].
689#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
690#[serde(from = "String", into = "String")]
691#[non_exhaustive]
692pub enum SessionTaskKey {
693    /// The user must choose or create an organization.
694    ChooseOrganization,
695    /// The user must reset their password.
696    ResetPassword,
697    /// The user must enroll in multi-factor authentication.
698    SetupMfa,
699    /// A task-key string this crate has not named yet.
700    ///
701    /// Only produced by the `From<&str>`/`From<String>` conversions, which
702    /// canonicalize known keys to their named variants first. The payload is an
703    /// [`OtherTaskKey`] with no public constructor, so an `Other` can never
704    /// alias a named variant.
705    Other(OtherTaskKey),
706}
707
708/// A clerk-js session task-key string with no named [`SessionTaskKey`] variant.
709///
710/// Obtained by matching on [`SessionTaskKey::Other`]; read the raw string with
711/// [`OtherTaskKey::as_str`]. It has no public constructor, so an `OtherTaskKey`
712/// never holds a value a named variant would represent.
713#[derive(Debug, Clone, PartialEq, Eq, Hash)]
714pub struct OtherTaskKey(String);
715
716impl OtherTaskKey {
717    /// The raw clerk-js task-key string.
718    pub fn as_str(&self) -> &str {
719        &self.0
720    }
721}
722
723impl std::fmt::Display for OtherTaskKey {
724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725        f.write_str(&self.0)
726    }
727}
728
729impl SessionTaskKey {
730    /// The raw clerk-js task-key string.
731    pub fn as_str(&self) -> &str {
732        match self {
733            Self::ChooseOrganization => "choose-organization",
734            Self::ResetPassword => "reset-password",
735            Self::SetupMfa => "setup-mfa",
736            Self::Other(key) => key.as_str(),
737        }
738    }
739
740    fn from_known(key: &str) -> Option<Self> {
741        Some(match key {
742            "choose-organization" => Self::ChooseOrganization,
743            "reset-password" => Self::ResetPassword,
744            "setup-mfa" => Self::SetupMfa,
745            _ => return None,
746        })
747    }
748}
749
750impl From<&str> for SessionTaskKey {
751    fn from(key: &str) -> Self {
752        Self::from_known(key).unwrap_or_else(|| Self::Other(OtherTaskKey(key.to_owned())))
753    }
754}
755
756impl From<String> for SessionTaskKey {
757    fn from(key: String) -> Self {
758        // Move the owned buffer into `Other` instead of re-allocating it.
759        Self::from_known(&key).unwrap_or(Self::Other(OtherTaskKey(key)))
760    }
761}
762
763impl From<SessionTaskKey> for String {
764    fn from(key: SessionTaskKey) -> Self {
765        key.as_str().to_owned()
766    }
767}
768
769impl std::str::FromStr for SessionTaskKey {
770    type Err = std::convert::Infallible;
771
772    fn from_str(key: &str) -> Result<Self, Self::Err> {
773        Ok(Self::from(key))
774    }
775}
776
777impl std::fmt::Display for SessionTaskKey {
778    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
779        f.write_str(self.as_str())
780    }
781}
782
783/// Typed mirror of the fields this crate reads from a Clerk JS `Session`.
784///
785/// Construct values with [`Session::new`] and set optional fields directly;
786/// the struct is `#[non_exhaustive]` so new clerk-js fields can be mirrored
787/// without a breaking release.
788#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
789#[non_exhaustive]
790pub struct Session {
791    /// Session id, e.g. `sess_2def`.
792    pub id: String,
793    /// Session status, as reported by clerk-js.
794    pub status: SessionStatus,
795    /// Active organization id for this session, as reported by clerk-js, if
796    /// any. Used to invalidate server-verified org claims when the active
797    /// organization changes within a session.
798    #[serde(default, alias = "lastActiveOrganizationId")]
799    pub last_active_organization_id: Option<String>,
800    /// Last activity (unix ms).
801    #[serde(default, alias = "lastActiveAt")]
802    pub last_active_at: Option<i64>,
803    /// Expiry (unix ms).
804    #[serde(default, alias = "expireAt")]
805    pub expire_at: Option<i64>,
806    /// The current pending session task, if the session is not yet fully
807    /// active. A signed-in-but-pending session (`status == "pending"`) carries
808    /// one; apps route on it to complete the after-auth flow.
809    #[serde(default, alias = "currentTask")]
810    pub current_task: Option<SessionTask>,
811    /// All pending session tasks, in clerk-js order. clerk-js reports
812    /// `Array<SessionTask> | null`; a `null` reads as no tasks.
813    #[serde(default, deserialize_with = "deserialize_null_default")]
814    pub tasks: Vec<SessionTask>,
815}
816
817/// Deserialize a possibly-`null` value into its `Default`, so clerk-js's
818/// `tasks: Array<SessionTask> | null` reads a `null` as an empty list rather
819/// than a deserialize error.
820fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
821where
822    D: serde::Deserializer<'de>,
823    T: Deserialize<'de> + Default,
824{
825    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
826}
827
828impl Session {
829    /// Creates a session with the given id and status and all optional fields
830    /// unset. Set remaining fields directly.
831    pub fn new(id: impl Into<String>, status: impl Into<SessionStatus>) -> Self {
832        Self {
833            id: id.into(),
834            status: status.into(),
835            last_active_organization_id: None,
836            last_active_at: None,
837            expire_at: None,
838            current_task: None,
839            tasks: Vec::new(),
840        }
841    }
842
843    /// True when clerk-js reports the session as active.
844    pub fn is_active(&self) -> bool {
845        self.status == SessionStatus::Active
846    }
847}
848
849#[cfg(test)]
850mod tests;