1use crate::SchemaValue;
8#[cfg(test)]
9use chrono::Utc;
10use serde::{Deserialize, Serialize, Serializer};
11use std::borrow::Cow;
12
13use crate::entity::{
14 AuthApiKey, AuthInvitation, AuthOrganization, AuthPasskey, AuthSession, AuthUser,
15};
16use crate::types::InvitationStatus;
17
18mod account_view;
19mod session_projection;
20mod verification_view;
21pub use account_view::AccountView;
22pub use verification_view::VerificationView;
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct UserView {
27 pub additional_fields: crate::FieldMap,
29 pub visible_fields: Option<std::collections::BTreeSet<String>>,
31 pub id: SchemaValue<String>,
32 pub name: SchemaValue<Option<String>>,
33 pub email: Option<String>,
34 pub email_verified: bool,
35 pub image: SchemaValue<Option<String>>,
36 pub created_at: crate::FieldDate,
37 pub updated_at: crate::FieldDate,
38 pub is_anonymous: Option<bool>,
39 pub phone_number: Option<String>,
40 pub phone_number_verified: Option<bool>,
41 pub username: Option<String>,
42 pub display_username: Option<String>,
43 pub two_factor_enabled: Option<bool>,
44 pub role: Option<String>,
45 pub banned: bool,
46 pub ban_reason: Option<String>,
47 pub ban_expires: Option<crate::FieldDate>,
48 pub metadata: crate::FieldValue,
49}
50
51#[derive(Debug, Clone, PartialEq)]
53pub struct SessionView {
54 pub visible_fields: Option<std::collections::BTreeSet<String>>,
56 pub id: SchemaValue<String>,
57 pub expires_at: crate::FieldDate,
58 pub token: String,
59 pub created_at: crate::FieldDate,
60 pub updated_at: crate::FieldDate,
61 pub ip_address: Option<String>,
62 pub user_agent: Option<String>,
63 pub user_id: SchemaValue<String>,
64 pub impersonated_by: Option<String>,
65 pub active_organization_id: Option<String>,
66 pub active_team_id: Option<String>,
67 pub active: bool,
68 pub additional_fields: crate::FieldMap,
69}
70
71impl From<crate::session::SessionData> for crate::FieldMap {
72 fn from(data: crate::session::SessionData) -> Self {
73 Self::from([
74 ("session".into(), Self::from(data.session).into()),
75 ("user".into(), Self::from(data.user).into()),
76 ])
77 }
78}
79
80impl crate::FromFieldMap for crate::session::SessionData {
81 fn from_field_values(fields: crate::FieldMap) -> crate::AuthResult<Self> {
82 fn record<T: crate::FromFieldMap>(
83 fields: &crate::FieldMap,
84 name: &str,
85 ) -> crate::AuthResult<T> {
86 let fields = fields
87 .get(name)
88 .and_then(crate::FieldValue::as_object)
89 .ok_or_else(|| {
90 crate::AuthError::internal(format!(
91 "Session response must contain a `{name}` object"
92 ))
93 })?;
94 T::from_field_values(fields.clone())
95 }
96 Ok(Self {
97 session: record(&fields, "session")?,
98 user: record(&fields, "user")?,
99 })
100 }
101}
102
103impl UserView {
104 pub fn from_model<T: AuthUser>(user: &T) -> crate::AuthResult<Self> {
106 let model = user.field_values()?;
107 Ok(Self {
108 additional_fields: user.projected_fields().cloned().unwrap_or_default(),
109 visible_fields: user.field_presence().cloned(),
110 id: user.id().into_owned(),
111 name: SchemaValue::from_field(
112 model
113 .get(T::serialized_field_name("name"))
114 .cloned()
115 .unwrap_or_default(),
116 ),
117 email: user.email().map(str::to_owned),
118 email_verified: user.email_verified(),
119 image: SchemaValue::from_field(
120 model
121 .get(T::serialized_field_name("image"))
122 .cloned()
123 .unwrap_or_default(),
124 ),
125 created_at: user.created_at(),
126 updated_at: user.updated_at(),
127 is_anonymous: user.is_anonymous(),
128 phone_number: user.phone_number().map(str::to_owned),
129 phone_number_verified: user.phone_number_verified(),
130 username: user.username().map(str::to_owned),
131 display_username: user.display_username().map(str::to_owned),
132 two_factor_enabled: match model.get(T::serialized_field_name("twoFactorEnabled")) {
133 Some(value) => value.decode()?,
134 None => Some(user.two_factor_enabled()),
135 },
136 role: user.role().map(str::to_owned),
137 banned: user.banned(),
138 ban_reason: user.ban_reason().map(str::to_owned),
139 ban_expires: user.ban_expires(),
140 metadata: model
141 .get(T::serialized_field_name("metadata"))
142 .cloned()
143 .unwrap_or(crate::FieldValue::Null),
144 })
145 }
146}
147
148impl From<&UserView> for UserView {
149 fn from(user: &UserView) -> Self {
150 user.clone()
151 }
152}
153
154impl<T: AuthSession> From<&T> for SessionView {
155 fn from(session: &T) -> Self {
156 Self {
157 visible_fields: Some(session.field_presence().cloned().unwrap_or_else(|| {
158 [
159 ("impersonated_by", "impersonatedBy"),
160 ("active_organization_id", "activeOrganizationId"),
161 ("active_team_id", "activeTeamId"),
162 ]
163 .into_iter()
164 .filter(|(field, _)| T::PLUGIN_FIELDS.contains(field))
165 .map(|(_, name)| name.to_owned())
166 .collect()
167 })),
168 id: session.id().into_owned(),
169 expires_at: session.expires_at(),
170 token: session.token().to_owned(),
171 created_at: session.created_at(),
172 updated_at: session.updated_at(),
173 ip_address: session.ip_address().map(str::to_owned),
174 user_agent: session.user_agent().map(str::to_owned),
175 user_id: session.user_id().into_owned(),
176 impersonated_by: session.impersonated_by().map(str::to_owned),
177 active_organization_id: session.active_organization_id().map(str::to_owned),
178 active_team_id: session.active_team_id().map(str::to_owned),
179 active: session.active(),
180 additional_fields: Default::default(),
181 }
182 }
183}
184
185impl SessionView {
186 pub(crate) fn active_plugin_fields(
187 metadata: &crate::plugin::MetadataMap,
188 ) -> impl Iterator<Item = &'static str> + '_ {
189 [
190 ("admin.enabled", "impersonatedBy"),
191 ("organization.enabled", "activeOrganizationId"),
192 ("organization.teams_enabled", "activeTeamId"),
193 ]
194 .into_iter()
195 .filter(move |(plugin, _)| {
196 metadata.get(*plugin).and_then(serde_json::Value::as_bool) == Some(true)
197 })
198 .map(|(_, field)| field)
199 }
200
201 pub fn filter_returned_fields(&mut self, config: &crate::config::SessionConfig) {
203 self.additional_fields.retain(|name, _| {
204 config
205 .fields()
206 .get(name)
207 .is_none_or(|field| field.returned())
208 });
209 }
210
211 pub async fn with_fields<T: AuthSession>(
213 session: &T,
214 config: &crate::config::SessionConfig,
215 ) -> crate::AuthResult<Self> {
216 Self::with_fields_for_adapter(session, config, true).await
217 }
218
219 pub async fn with_fields_for_adapter<T: AuthSession>(
220 session: &T,
221 config: &crate::config::SessionConfig,
222 supports_native_json: bool,
223 ) -> crate::AuthResult<Self> {
224 let mut view =
225 Self::with_internal_fields_for_adapter(session, config, supports_native_json).await?;
226 view.filter_returned_fields(config);
227 Ok(view)
228 }
229
230 pub async fn with_internal_fields_for_adapter<T: AuthSession>(
232 session: &T,
233 config: &crate::config::SessionConfig,
234 supports_native_json: bool,
235 ) -> crate::AuthResult<Self> {
236 Ok(Self::with_internal_fields_many_for_adapter(
238 std::slice::from_ref(session),
239 config,
240 supports_native_json,
241 )
242 .await?
243 .remove(0))
244 }
245
246 pub async fn with_internal_fields_many_for_adapter<T: AuthSession>(
248 sessions: &[T],
249 config: &crate::config::SessionConfig,
250 supports_native_json: bool,
251 ) -> crate::AuthResult<Vec<Self>> {
252 Self::with_internal_fields_many_for_adapter_then(
253 sessions,
254 config,
255 supports_native_json,
256 |_, session| std::future::ready(Ok(session)),
257 )
258 .await
259 }
260
261 pub async fn with_internal_fields_many_for_adapter_then<T: AuthSession, R: Send, F>(
264 sessions: &[T],
265 config: &crate::config::SessionConfig,
266 supports_native_json: bool,
267 complete: impl Fn(usize, Self) -> F + Sync,
268 ) -> crate::AuthResult<Vec<R>>
269 where
270 F: std::future::Future<Output = crate::AuthResult<R>> + Send,
271 {
272 let mut rows = session_projection::rows(sessions, config)?;
273 let schema = config.adapter_schema();
274 crate::user_fields::project_fields_then(
275 &mut rows,
276 schema.fields(),
277 |row, name, field| Box::pin(row.project(name, field, supports_native_json)),
278 |index, row| complete(index, row.view.clone().into_projected_fields()),
279 )
280 .await
281 }
282
283 pub async fn with_internal_fields_many_for_adapter_batches_then<T: AuthSession, R: Send, F>(
285 sessions: &[T],
286 config: &crate::config::SessionConfig,
287 supports_native_json: bool,
288 complete: impl Fn(Vec<(usize, Self)>) -> F + Sync,
289 ) -> crate::AuthResult<Vec<R>>
290 where
291 F: std::future::Future<Output = crate::AuthResult<Vec<(usize, R)>>> + Send,
292 {
293 let mut rows = session_projection::rows(sessions, config)?;
294 let schema = config.adapter_schema();
295 crate::user_fields::project_fields_batches_then(
296 &mut rows,
297 schema.fields(),
298 |row, name, field| Box::pin(row.project(name, field, supports_native_json)),
299 |_, row| Ok(row.view.clone().into_projected_fields()),
300 complete,
301 )
302 .await
303 }
304}
305
306impl AuthUser for UserView {
307 fn field_presence(&self) -> Option<&std::collections::BTreeSet<String>> {
308 self.visible_fields.as_ref()
309 }
310 fn projected_fields(&self) -> Option<&crate::FieldMap> {
311 Some(&self.additional_fields)
312 }
313 const PLUGIN_FIELDS: &'static [&'static str] = &[
314 "is_anonymous",
315 "phone_number",
316 "phone_number_verified",
317 "username",
318 "display_username",
319 "two_factor_enabled",
320 "role",
321 "banned",
322 "ban_reason",
323 "ban_expires",
324 "metadata",
325 ];
326 fn id(&self) -> SchemaValue<Cow<'_, str>> {
327 self.id.as_ref().map(|id| Cow::Borrowed(id.as_str()))
328 }
329 fn email(&self) -> Option<&str> {
330 self.email.as_deref()
331 }
332 fn email_verified(&self) -> bool {
333 self.email_verified
334 }
335 fn created_at(&self) -> crate::FieldDate {
336 self.created_at.clone()
337 }
338 fn updated_at(&self) -> crate::FieldDate {
339 self.updated_at.clone()
340 }
341 fn is_anonymous(&self) -> Option<bool> {
342 self.is_anonymous
343 }
344 fn phone_number(&self) -> Option<&str> {
345 self.phone_number.as_deref()
346 }
347 fn phone_number_verified(&self) -> Option<bool> {
348 self.phone_number_verified
349 }
350 fn username(&self) -> Option<&str> {
351 self.username.as_deref()
352 }
353 fn display_username(&self) -> Option<&str> {
354 self.display_username.as_deref()
355 }
356 fn two_factor_enabled(&self) -> bool {
357 self.two_factor_enabled == Some(true)
358 }
359 fn role(&self) -> Option<&str> {
360 self.role.as_deref()
361 }
362 fn banned(&self) -> bool {
363 self.banned
364 }
365 fn ban_reason(&self) -> Option<&str> {
366 self.ban_reason.as_deref()
367 }
368 fn ban_expires(&self) -> Option<crate::FieldDate> {
369 self.ban_expires.clone()
370 }
371}
372
373impl AuthSession for SessionView {
374 fn field_presence(&self) -> Option<&std::collections::BTreeSet<String>> {
375 self.visible_fields.as_ref()
376 }
377 fn projected_fields(&self) -> Option<&crate::FieldMap> {
378 Some(&self.additional_fields)
379 }
380
381 const PLUGIN_FIELDS: &'static [&'static str] = &[
382 "impersonated_by",
383 "active_organization_id",
384 "active_team_id",
385 ];
386 fn id(&self) -> SchemaValue<Cow<'_, str>> {
387 self.id.as_ref().map(|id| Cow::Borrowed(id.as_str()))
388 }
389 fn expires_at(&self) -> crate::FieldDate {
390 self.expires_at.clone()
391 }
392 fn token(&self) -> &str {
393 &self.token
394 }
395 fn created_at(&self) -> crate::FieldDate {
396 self.created_at.clone()
397 }
398 fn updated_at(&self) -> crate::FieldDate {
399 self.updated_at.clone()
400 }
401 fn ip_address(&self) -> Option<&str> {
402 self.ip_address.as_deref()
403 }
404 fn user_agent(&self) -> Option<&str> {
405 self.user_agent.as_deref()
406 }
407 fn user_id(&self) -> SchemaValue<Cow<'_, str>> {
408 self.user_id.as_ref().map(|id| Cow::Borrowed(id.as_str()))
409 }
410 fn impersonated_by(&self) -> Option<&str> {
411 self.impersonated_by.as_deref()
412 }
413 fn active_team_id(&self) -> Option<&str> {
414 self.active_team_id.as_deref()
415 }
416 fn active_organization_id(&self) -> Option<&str> {
417 self.active_organization_id.as_deref()
418 }
419 fn active(&self) -> bool {
420 self.active
421 }
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
430pub struct OrganizationView {
431 #[serde(with = "crate::field_value::serde::map", flatten)]
432 pub additional_fields: crate::FieldMap,
433
434 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
435 pub id: SchemaValue<String>,
436 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
437 pub name: SchemaValue<String>,
438 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
439 pub slug: SchemaValue<String>,
440 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
441 pub logo: SchemaValue<Option<String>>,
442 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
443 pub metadata: SchemaValue<Option<crate::FieldValue>>,
444 #[serde(rename = "createdAt")]
445 #[serde(with = "crate::field_value::serde::schema_date")]
446 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
447 pub created_at: SchemaValue<crate::FieldDate>,
448}
449
450impl<T: AuthOrganization> From<&T> for OrganizationView {
451 fn from(org: &T) -> Self {
452 Self {
453 additional_fields: org.projected_fields().cloned().unwrap_or_default(),
454 id: org.id().into_owned(),
455 name: org.name().clone(),
456 slug: org.slug().clone(),
457 logo: org.logo().clone(),
458 metadata: org.metadata().clone(),
459 created_at: org.created_at().clone(),
460 }
461 }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
466pub struct InvitationView {
467 #[serde(with = "crate::field_value::serde::map", flatten)]
468 pub additional_fields: crate::FieldMap,
469
470 #[serde(rename = "teamId")]
471 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
472 pub team_id: SchemaValue<Option<String>>,
473 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
474 pub id: SchemaValue<String>,
475 #[serde(rename = "organizationId")]
476 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
477 pub organization_id: SchemaValue<String>,
478 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
479 pub email: SchemaValue<String>,
480 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
481 pub role: SchemaValue<String>,
482 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
483 pub status: SchemaValue<InvitationStatus>,
484 #[serde(rename = "inviterId")]
485 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
486 pub inviter_id: SchemaValue<String>,
487 #[serde(rename = "expiresAt")]
488 #[serde(with = "crate::field_value::serde::schema_date")]
489 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
490 pub expires_at: SchemaValue<crate::FieldDate>,
491 #[serde(rename = "createdAt")]
492 #[serde(with = "crate::field_value::serde::schema_date")]
493 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
494 pub created_at: SchemaValue<crate::FieldDate>,
495}
496
497impl<T: AuthInvitation> From<&T> for InvitationView {
498 fn from(inv: &T) -> Self {
499 Self {
500 additional_fields: inv.projected_fields().cloned().unwrap_or_default(),
501 id: inv.id().into_owned(),
502 organization_id: inv.organization_id().clone(),
503 email: inv.email().clone(),
504 role: inv.role().clone(),
505 status: inv.status().clone(),
506 inviter_id: inv.inviter_id().clone(),
507 team_id: inv.team_id().clone(),
508 expires_at: inv.expires_at().clone(),
509 created_at: inv.created_at().clone(),
510 }
511 }
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
516pub struct PasskeyView {
517 #[serde(with = "crate::field_value::serde::map", flatten, default)]
519 pub additional_fields: crate::FieldMap,
520 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
521 pub id: SchemaValue<String>,
522 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
523 pub name: SchemaValue<Option<String>>,
524 #[serde(rename = "credentialID")]
525 pub credential_id: String,
526 #[serde(rename = "userId")]
527 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
528 pub user_id: SchemaValue<String>,
529 #[serde(rename = "publicKey")]
530 pub public_key: String,
531 pub counter: u64,
532 #[serde(rename = "deviceType")]
533 pub device_type: String,
534 #[serde(rename = "backedUp")]
535 pub backed_up: bool,
536 pub transports: Option<String>,
537 #[serde(rename = "createdAt")]
538 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
539 #[serde(with = "crate::field_value::serde::optional_schema_date")]
540 pub created_at: SchemaValue<Option<crate::FieldDate>>,
541 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
542 pub aaguid: SchemaValue<Option<String>>,
543}
544
545impl<T: AuthPasskey> From<&T> for PasskeyView {
546 fn from(pk: &T) -> Self {
547 Self {
548 additional_fields: pk.additional_fields().cloned().unwrap_or_default(),
549 id: pk.id().into_owned(),
550 name: pk.name().clone(),
551 credential_id: pk.credential_id().to_owned(),
552 user_id: pk.user_id().into_owned(),
553 public_key: pk.public_key().to_owned(),
554 counter: pk.counter(),
555 device_type: pk.device_type().to_owned(),
556 backed_up: pk.backed_up(),
557 transports: pk.transports().map(str::to_owned),
558 created_at: pk.created_at().clone(),
559 aaguid: pk.aaguid().clone(),
560 }
561 }
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
569pub struct ApiKeyView {
570 #[serde(with = "crate::field_value::serde::map", flatten, default)]
572 pub additional_fields: crate::FieldMap,
573 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
574 pub id: SchemaValue<String>,
575 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
576 pub name: SchemaValue<Option<String>>,
577 pub start: Option<crate::ApiKeyStart>,
578 pub prefix: Option<String>,
579 #[serde(rename = "referenceId")]
580 pub reference_id: String,
581 #[serde(rename = "configId")]
582 pub config_id: String,
583 #[serde(rename = "refillInterval")]
584 #[serde(serialize_with = "serialize_optional_number")]
585 pub refill_interval: Option<f64>,
586 #[serde(rename = "refillAmount")]
587 #[serde(serialize_with = "serialize_optional_number")]
588 pub refill_amount: Option<f64>,
589 #[serde(rename = "lastRefillAt")]
590 #[serde(default, with = "crate::field_value::serde::optional_date")]
591 pub last_refill_at: Option<crate::FieldDate>,
592 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
593 pub enabled: SchemaValue<bool>,
594 #[serde(rename = "rateLimitEnabled")]
595 #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
596 pub rate_limit_enabled: SchemaValue<bool>,
597 #[serde(rename = "rateLimitTimeWindow")]
598 #[serde(serialize_with = "serialize_optional_number")]
599 pub rate_limit_time_window: Option<f64>,
600 #[serde(rename = "rateLimitMax")]
601 #[serde(serialize_with = "serialize_optional_number")]
602 pub rate_limit_max: Option<f64>,
603 #[serde(rename = "requestCount")]
604 #[serde(serialize_with = "serialize_optional_number")]
605 pub request_count: Option<f64>,
606 #[serde(serialize_with = "serialize_optional_number")]
607 pub remaining: Option<f64>,
608 #[serde(rename = "lastRequest")]
609 #[serde(default, with = "crate::field_value::serde::optional_date")]
610 pub last_request: Option<crate::FieldDate>,
611 #[serde(rename = "expiresAt")]
612 #[serde(default, with = "crate::field_value::serde::optional_date")]
613 pub expires_at: Option<crate::FieldDate>,
614 #[serde(rename = "createdAt")]
615 #[serde(with = "crate::field_value::serde::date")]
616 pub created_at: crate::FieldDate,
617 #[serde(rename = "updatedAt")]
618 #[serde(with = "crate::field_value::serde::date")]
619 pub updated_at: crate::FieldDate,
620 pub permissions: Option<serde_json::Value>,
621 pub metadata: Option<serde_json::Value>,
622}
623
624pub fn serialize_optional_number<S: Serializer>(
626 value: &Option<f64>,
627 serializer: S,
628) -> Result<S::Ok, S::Error> {
629 match value {
630 Some(value) if value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0 => {
631 serializer.serialize_i64(*value as i64)
632 }
633 value => value.serialize(serializer),
634 }
635}
636
637impl<T: AuthApiKey> From<&T> for ApiKeyView {
638 fn from(ak: &T) -> Self {
639 Self {
640 additional_fields: ak.additional_fields().cloned().unwrap_or_default(),
641 id: ak.id().into_owned(),
642 name: ak.name().clone(),
643 start: ak.start().map(std::borrow::Cow::into_owned),
644 prefix: ak.prefix().map(str::to_owned),
645 reference_id: ak.reference_id().into_owned(),
646 config_id: ak.config_id().into_owned(),
647 refill_interval: ak.refill_interval(),
648 refill_amount: ak.refill_amount(),
649 last_refill_at: ak.last_refill_at(),
650 enabled: ak.enabled().clone(),
651 rate_limit_enabled: ak.rate_limit_enabled().clone(),
652 rate_limit_time_window: ak.rate_limit_time_window(),
653 rate_limit_max: ak.rate_limit_max(),
654 request_count: ak.request_count(),
655 remaining: ak.remaining(),
656 last_request: ak.last_request(),
657 expires_at: ak.expires_at(),
658 created_at: ak.created_at().to_owned(),
659 updated_at: ak.updated_at().to_owned(),
660 permissions: ak.permissions().and_then(|s| serde_json::from_str(s).ok()),
661 metadata: ak.metadata().and_then(|s| serde_json::from_str(s).ok()),
662 }
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 #[test]
671 fn user_view_serializes_camel_case() {
672 let user = UserView {
673 visible_fields: None,
674 additional_fields: Default::default(),
675 id: "user-1".to_string().into(),
676 name: Some("Ada".to_string()).into(),
677 email: Some("ada@example.com".to_string()),
678 email_verified: true,
679 image: Default::default(),
680 created_at: Utc::now().into(),
681 updated_at: Utc::now().into(),
682 is_anonymous: None,
683 phone_number: None,
684 phone_number_verified: None,
685 username: Some("ada".to_string()),
686 display_username: Some("Ada".to_string()),
687 two_factor_enabled: Some(true),
688 role: Some("admin".to_string()),
689 banned: false,
690 ban_reason: None,
691 ban_expires: None,
692 metadata: crate::FieldMap::new().into(),
693 };
694
695 let json = serde_json::to_value(UserView::from(&user)).expect("serialize user view");
696 assert_eq!(json["emailVerified"], true);
697 assert_eq!(json["displayUsername"], "Ada");
698 assert_eq!(json["twoFactorEnabled"], true);
699 }
700
701 #[tokio::test]
702 async fn two_factor_enabled_preserves_null_through_projection_and_cache() {
703 let metadata = crate::plugin::MetadataMap::from_iter([(
704 "two_factor.enabled".to_owned(),
705 serde_json::json!(true),
706 )]);
707 for value in [None, Some(false), Some(true)] {
708 let user: UserView = serde_json::from_value(serde_json::json!({
709 "id": "owner",
710 "name": "Owner",
711 "email": "owner@example.com",
712 "emailVerified": true,
713 "createdAt": "2026-10-07T00:00:00.000Z",
714 "updatedAt": "2026-10-07T00:00:00.000Z",
715 "twoFactorEnabled": value,
716 }))
717 .expect("user response");
718 let projected = UserView::with_fields(&user, &Default::default(), &metadata)
719 .await
720 .expect("project nullable plugin field");
721 let cached: UserView = serde_json::from_value(
722 serde_json::to_value(&projected).expect("serialize signed cache user"),
723 )
724 .expect("deserialize signed cache user");
725 let projected = UserView::with_fields(&cached, &Default::default(), &metadata)
726 .await
727 .expect("reproject cached user");
728 assert_eq!(projected.two_factor_enabled, value);
729 assert_eq!(projected.two_factor_enabled(), value == Some(true));
730 assert_eq!(
731 serde_json::to_value(projected)
732 .expect("serialize public user")
733 .get("twoFactorEnabled"),
734 Some(&serde_json::json!(value)),
735 );
736 }
737 }
738
739 #[test]
740 fn session_view_serializes_camel_case() {
741 let session = SessionView {
742 visible_fields: None,
743 id: "session-1".to_string().into(),
744 expires_at: Utc::now().into(),
745 token: "token".to_string(),
746 created_at: Utc::now().into(),
747 updated_at: Utc::now().into(),
748 ip_address: Some("127.0.0.1".to_string()),
749 user_agent: Some("agent".to_string()),
750 user_id: "user-1".to_string().into(),
751 impersonated_by: Some("admin-1".to_string()),
752 active_organization_id: Some("org-1".to_string()),
753 active_team_id: None,
754 active: true,
755 additional_fields: Default::default(),
756 };
757
758 let json =
759 serde_json::to_value(SessionView::from(&session)).expect("serialize session view");
760 assert_eq!(json["expiresAt"].is_string(), true);
761 assert_eq!(json["ipAddress"], "127.0.0.1");
762 assert_eq!(json["activeOrganizationId"], "org-1");
763 }
764
765 #[test]
766 fn account_view_omits_password_on_serialize() {
767 let account = AccountView {
768 additional_fields: Default::default(),
769 id: "acc-1".to_string().into(),
770 account_id: "account-id".to_string().into(),
771 provider_id: "credential".to_string().into(),
772 user_id: "user-1".to_string().into(),
773 access_token: None.into(),
774 refresh_token: None.into(),
775 id_token: None.into(),
776 access_token_expires_at: None.into(),
777 refresh_token_expires_at: None.into(),
778 scope: None.into(),
779 password: Some("$2a$hash".to_string()).into(),
780 created_at: Utc::now().into(),
781 updated_at: Utc::now().into(),
782 };
783
784 let json = serde_json::to_value(&account).expect("serialize account view");
785 assert!(
786 json.get("password").is_none(),
787 "password field must not appear in serialized output"
788 );
789 }
790}