Skip to main content

reinhardt_pages/
auth.rs

1//! Authentication State Management for Client-side WASM
2//!
3//! This module provides reactive authentication state management for client-side
4//! WASM applications. It integrates with the `reinhardt-auth` session system
5//! and provides a Django-like interface for accessing user information.
6//!
7//! ## Usage
8//!
9//! ```ignore
10//! use reinhardt_pages::auth::{AuthState, auth_state};
11//!
12//! // Get the global auth state
13//! let auth = auth_state();
14//!
15//! // Check authentication status (reactive)
16//! if auth.is_authenticated() {
17//!     println!("User: {}", auth.username().unwrap_or_default());
18//! }
19//!
20//! // React to authentication changes
21//! Effect::new(move || {
22//!     if auth.is_authenticated() {
23//!         // Show user dashboard
24//!     } else {
25//!         // Show login form
26//!     }
27//! });
28//! ```
29
30use crate::reactive::Signal;
31use std::cell::RefCell;
32use std::collections::HashSet;
33
34/// Deserializes a user ID that may be either a JSON string or a JSON number.
35///
36/// This provides backward compatibility: existing clients that send `"user_id": 42`
37/// (integer) will have it converted to `Some("42")`, while new clients sending
38/// `"user_id": "550e8400-..."` (UUID string) work directly.
39fn deserialize_user_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
40where
41	D: serde::Deserializer<'de>,
42{
43	use serde::Deserialize;
44	let value = Option::<serde_json::Value>::deserialize(deserializer)?;
45	Ok(value.and_then(|v| match v {
46		serde_json::Value::String(s) if s.is_empty() => None,
47		serde_json::Value::String(s) => Some(s),
48		serde_json::Value::Number(n) => Some(n.to_string()),
49		serde_json::Value::Null => None,
50		_ => None,
51	}))
52}
53
54/// Session key for user ID (matches reinhardt-auth).
55pub const SESSION_KEY_USER_ID: &str = "_auth_user_id";
56
57/// Session key for username.
58pub const SESSION_KEY_USERNAME: &str = "_auth_username";
59
60/// Cookie name for session ID (matches reinhardt-auth).
61pub const SESSION_COOKIE_NAME: &str = "sessionid";
62
63thread_local! {
64	/// Global authentication state instance.
65	static AUTH_STATE: RefCell<Option<AuthState>> = const { RefCell::new(None) };
66}
67
68/// Returns the global authentication state.
69///
70/// This creates the state on first access and returns the same instance
71/// for subsequent calls within the same thread.
72pub fn auth_state() -> AuthState {
73	AUTH_STATE.with(|state| {
74		let mut state = state.borrow_mut();
75		if state.is_none() {
76			*state = Some(AuthState::new());
77		}
78		state.clone().unwrap()
79	})
80}
81
82/// Reactive authentication state for client-side applications.
83///
84/// This struct provides reactive signals that automatically update
85/// when authentication state changes. It can be used to build
86/// authentication-aware UI components.
87#[derive(Debug, Clone)]
88pub struct AuthState {
89	/// Whether the user is authenticated.
90	is_authenticated: Signal<bool>,
91	/// The authenticated user's ID (string to support both integer and UUID PKs).
92	user_id: Signal<Option<String>>,
93	/// The authenticated user's username.
94	username: Signal<Option<String>>,
95	/// The authenticated user's email.
96	email: Signal<Option<String>>,
97	/// Whether the user is a staff member.
98	is_staff: Signal<bool>,
99	/// Whether the user is a superuser.
100	is_superuser: Signal<bool>,
101	/// User's permissions (cached from server).
102	permissions: Signal<HashSet<String>>,
103}
104
105impl Default for AuthState {
106	fn default() -> Self {
107		Self::new()
108	}
109}
110
111impl AuthState {
112	/// Creates a new authentication state with default (unauthenticated) values.
113	pub fn new() -> Self {
114		Self {
115			is_authenticated: Signal::new(false),
116			user_id: Signal::new(None),
117			username: Signal::new(None),
118			email: Signal::new(None),
119			is_staff: Signal::new(false),
120			is_superuser: Signal::new(false),
121			permissions: Signal::new(HashSet::new()),
122		}
123	}
124
125	/// Creates an authentication state from server-provided data.
126	///
127	/// This is typically used during hydration when the server
128	/// embeds authentication data in the initial HTML.
129	pub fn from_server_data(data: AuthData) -> Self {
130		Self {
131			is_authenticated: Signal::new(data.is_authenticated),
132			user_id: Signal::new(data.user_id),
133			username: Signal::new(data.username),
134			email: Signal::new(data.email),
135			is_staff: Signal::new(data.is_staff),
136			is_superuser: Signal::new(data.is_superuser),
137			permissions: Signal::new(data.permissions.into_iter().collect()),
138		}
139	}
140
141	/// Returns whether the user is authenticated.
142	pub fn is_authenticated(&self) -> bool {
143		self.is_authenticated.get()
144	}
145
146	/// Returns the authenticated user's ID.
147	pub fn user_id(&self) -> Option<String> {
148		self.user_id.get()
149	}
150
151	/// Returns the authenticated user's username.
152	pub fn username(&self) -> Option<String> {
153		self.username.get()
154	}
155
156	/// Returns the authenticated user's email.
157	pub fn email(&self) -> Option<String> {
158		self.email.get()
159	}
160
161	/// Returns whether the user is a staff member.
162	pub fn is_staff(&self) -> bool {
163		self.is_staff.get()
164	}
165
166	/// Returns whether the user is a superuser.
167	pub fn is_superuser(&self) -> bool {
168		self.is_superuser.get()
169	}
170
171	/// Returns the Signal for authentication status.
172	///
173	/// Use this for reactive UI updates.
174	pub fn is_authenticated_signal(&self) -> Signal<bool> {
175		self.is_authenticated.clone()
176	}
177
178	/// Returns the Signal for user ID.
179	pub fn user_id_signal(&self) -> Signal<Option<String>> {
180		self.user_id.clone()
181	}
182
183	/// Returns the Signal for username.
184	pub fn username_signal(&self) -> Signal<Option<String>> {
185		self.username.clone()
186	}
187
188	/// Returns the Signal for email.
189	pub fn email_signal(&self) -> Signal<Option<String>> {
190		self.email.clone()
191	}
192
193	/// Returns the Signal for staff status.
194	pub fn is_staff_signal(&self) -> Signal<bool> {
195		self.is_staff.clone()
196	}
197
198	/// Returns the Signal for superuser status.
199	pub fn is_superuser_signal(&self) -> Signal<bool> {
200		self.is_superuser.clone()
201	}
202
203	/// Updates the authentication state with new data.
204	///
205	/// This is typically called after a successful login or
206	/// when session data is refreshed from the server.
207	pub fn update(&self, data: AuthData) {
208		self.is_authenticated.set(data.is_authenticated);
209		self.user_id.set(data.user_id);
210		self.username.set(data.username);
211		self.email.set(data.email);
212		self.is_staff.set(data.is_staff);
213		self.is_superuser.set(data.is_superuser);
214		self.permissions.set(data.permissions.into_iter().collect());
215	}
216
217	/// Sets the state to authenticated with the given user data.
218	///
219	/// Resets `email`, `is_staff`, `is_superuser`, and `permissions` to defaults
220	/// to prevent stale data from a previous session.
221	pub fn login(&self, user_id: impl Into<String>, username: impl Into<String>) {
222		self.is_authenticated.set(true);
223		self.user_id.set(Some(user_id.into()));
224		self.username.set(Some(username.into()));
225		self.email.set(None);
226		self.is_staff.set(false);
227		self.is_superuser.set(false);
228		self.permissions.set(HashSet::new());
229	}
230
231	/// Sets the state to authenticated with full user data.
232	pub fn login_full(
233		&self,
234		user_id: impl Into<String>,
235		username: impl Into<String>,
236		email: Option<String>,
237		is_staff: bool,
238		is_superuser: bool,
239	) {
240		self.is_authenticated.set(true);
241		self.user_id.set(Some(user_id.into()));
242		self.username.set(Some(username.into()));
243		self.email.set(email);
244		self.is_staff.set(is_staff);
245		self.is_superuser.set(is_superuser);
246	}
247
248	/// Clears the authentication state (logout).
249	pub fn logout(&self) {
250		self.is_authenticated.set(false);
251		self.user_id.set(None);
252		self.username.set(None);
253		self.email.set(None);
254		self.is_staff.set(false);
255		self.is_superuser.set(false);
256		self.permissions.set(HashSet::new());
257	}
258
259	/// Checks if the user has the given permission.
260	///
261	/// Note: This is a client-side check only. Always verify
262	/// permissions on the server for security.
263	pub fn has_permission(&self, permission: &str) -> bool {
264		// Superusers have all permissions
265		if self.is_superuser() {
266			return true;
267		}
268
269		// Check permission cache
270		self.permissions.get().contains(permission)
271	}
272
273	/// Checks if the user has any of the given permissions.
274	///
275	/// Note: This is a client-side check only. Always verify
276	/// permissions on the server for security.
277	pub fn has_any_permission(&self, permissions: &[&str]) -> bool {
278		if self.is_superuser() {
279			return true;
280		}
281		let cached = self.permissions.get();
282		permissions.iter().any(|p| cached.contains(*p))
283	}
284
285	/// Checks if the user has all of the given permissions.
286	///
287	/// Note: This is a client-side check only. Always verify
288	/// permissions on the server for security.
289	pub fn has_all_permissions(&self, permissions: &[&str]) -> bool {
290		if self.is_superuser() {
291			return true;
292		}
293		let cached = self.permissions.get();
294		permissions.iter().all(|p| cached.contains(*p))
295	}
296
297	/// Updates the permission cache.
298	pub fn set_permissions(&self, permissions: HashSet<String>) {
299		self.permissions.set(permissions);
300	}
301
302	/// Returns the Signal for permissions (for reactive updates).
303	pub fn permissions_signal(&self) -> Signal<HashSet<String>> {
304		self.permissions.clone()
305	}
306
307	/// Fetches permissions from the server and updates the cache.
308	///
309	/// Default endpoint: `/api/auth/permissions`
310	#[cfg(wasm)]
311	pub async fn fetch_permissions(&self, endpoint: Option<&str>) -> Result<(), AuthError> {
312		use crate::csrf::csrf_headers;
313		use crate::fetch;
314
315		let endpoint = endpoint.unwrap_or("/api/auth/permissions");
316		let mut headers = Vec::new();
317		if let Some((header_name, header_value)) = csrf_headers() {
318			headers.push((header_name.to_string(), header_value));
319		}
320
321		let response = fetch::request("GET", endpoint, None, headers)
322			.await
323			.map_err(|e| AuthError::Network(e.to_string()))?;
324
325		if !response.is_success() {
326			return Err(AuthError::Server {
327				status: response.status(),
328				message: response.into_text(),
329			});
330		}
331
332		let permissions: Vec<String> = response
333			.json()
334			.map_err(|e| AuthError::Parse(e.to_string()))?;
335
336		self.permissions.set(permissions.into_iter().collect());
337		Ok(())
338	}
339
340	/// Fetches permissions from the server (non-WASM stub).
341	#[cfg(native)]
342	pub async fn fetch_permissions(&self, _endpoint: Option<&str>) -> Result<(), AuthError> {
343		Ok(())
344	}
345
346	/// Initializes the auth state from embedded data in the page.
347	///
348	/// This looks for a `<script id="auth-data">` element containing
349	/// JSON-encoded authentication data.
350	#[cfg(wasm)]
351	pub fn init_from_page(&self) {
352		use web_sys::window;
353
354		let Some(window) = window() else { return };
355		let Some(document) = window.document() else {
356			return;
357		};
358
359		// Try to find embedded auth data
360		let Ok(Some(element)) = document.query_selector("#auth-data") else {
361			return;
362		};
363
364		let Some(json_str) = element.text_content() else {
365			return;
366		};
367
368		if let Ok(data) = serde_json::from_str::<AuthData>(&json_str) {
369			self.update(data);
370		}
371	}
372
373	/// Initializes the auth state (non-WASM stub).
374	#[cfg(native)]
375	pub fn init_from_page(&self) {
376		// No-op on non-WASM targets
377	}
378
379	/// Fetches the current auth state from the server.
380	///
381	/// This makes an AJAX request to the auth status endpoint
382	/// and updates the state with the response.
383	#[cfg(wasm)]
384	pub async fn fetch_from_server(&self, endpoint: &str) -> Result<(), AuthError> {
385		use crate::csrf::csrf_headers;
386		use crate::fetch;
387
388		let mut headers = Vec::new();
389		if let Some((header_name, header_value)) = csrf_headers() {
390			headers.push((header_name.to_string(), header_value));
391		}
392
393		let response = fetch::request("GET", endpoint, None, headers)
394			.await
395			.map_err(|e| AuthError::Network(e.to_string()))?;
396
397		if !response.is_success() {
398			return Err(AuthError::Server {
399				status: response.status(),
400				message: response.into_text(),
401			});
402		}
403
404		let data: AuthData = response
405			.json()
406			.map_err(|e| AuthError::Parse(e.to_string()))?;
407
408		self.update(data);
409		Ok(())
410	}
411
412	/// Fetches the current auth state (non-WASM stub).
413	#[cfg(native)]
414	pub async fn fetch_from_server(&self, _endpoint: &str) -> Result<(), AuthError> {
415		Ok(())
416	}
417}
418
419/// Authentication data that can be serialized/deserialized.
420///
421/// This is used for:
422/// - Embedding auth data in SSR HTML
423/// - Server responses to auth status requests
424/// - Hydration of client-side auth state
425#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
426pub struct AuthData {
427	/// Whether the user is authenticated.
428	pub is_authenticated: bool,
429	/// The authenticated user's ID (string to support both integer and UUID PKs).
430	#[serde(default, deserialize_with = "deserialize_user_id")]
431	pub user_id: Option<String>,
432	/// The authenticated user's username.
433	#[serde(default)]
434	pub username: Option<String>,
435	/// The authenticated user's email.
436	#[serde(default)]
437	pub email: Option<String>,
438	/// Whether the user is a staff member.
439	#[serde(default)]
440	pub is_staff: bool,
441	/// Whether the user is a superuser.
442	#[serde(default)]
443	pub is_superuser: bool,
444	/// User's permissions.
445	#[serde(default)]
446	pub permissions: Vec<String>,
447}
448
449impl AuthData {
450	/// Creates anonymous (unauthenticated) auth data.
451	pub fn anonymous() -> Self {
452		Self::default()
453	}
454
455	/// Creates authenticated auth data with minimal info.
456	pub fn authenticated(user_id: impl Into<String>, username: impl Into<String>) -> Self {
457		Self {
458			is_authenticated: true,
459			user_id: Some(user_id.into()),
460			username: Some(username.into()),
461			..Default::default()
462		}
463	}
464
465	/// Creates authenticated auth data with full info.
466	pub fn full(
467		user_id: impl Into<String>,
468		username: impl Into<String>,
469		email: Option<String>,
470		is_staff: bool,
471		is_superuser: bool,
472	) -> Self {
473		Self {
474			is_authenticated: true,
475			user_id: Some(user_id.into()),
476			username: Some(username.into()),
477			email,
478			is_staff,
479			is_superuser,
480			permissions: Vec::new(),
481		}
482	}
483}
484
485/// Errors that can occur during authentication operations.
486#[derive(Debug, Clone)]
487pub enum AuthError {
488	/// Network error during request.
489	Network(String),
490	/// Server returned an error response.
491	Server {
492		/// HTTP status code.
493		status: u16,
494		/// Error message.
495		message: String,
496	},
497	/// Failed to parse response.
498	Parse(String),
499}
500
501impl std::fmt::Display for AuthError {
502	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503		match self {
504			AuthError::Network(msg) => write!(f, "Network error: {}", msg),
505			AuthError::Server { status, message } => {
506				write!(f, "Server error ({}): {}", status, message)
507			}
508			AuthError::Parse(msg) => write!(f, "Parse error: {}", msg),
509		}
510	}
511}
512
513impl std::error::Error for AuthError {}
514
515// ============================================================================
516// JWT Token Management for Client-side WASM
517// ============================================================================
518
519/// The HTTP header name for JWT Bearer token authentication.
520pub const AUTH_HEADER_NAME: &str = "Authorization";
521
522/// The sessionStorage key for the admin JWT token.
523///
524/// sessionStorage is preferred over localStorage for admin panels because:
525/// - Tokens are scoped per tab and cleared when the tab closes
526/// - Reduces exposure from XSS attacks on other tabs
527pub const JWT_STORAGE_KEY: &str = "__admin_jwt";
528
529/// Creates HTTP headers with JWT Bearer token for authenticated requests.
530///
531/// Returns a tuple of (header_name, header_value) if a JWT token is available
532/// in sessionStorage. This follows the same pattern as [`crate::csrf::csrf_headers`].
533///
534/// # Example
535///
536/// ```ignore
537/// use reinhardt_pages::auth::auth_headers;
538///
539/// if let Some((header_name, header_value)) = auth_headers() {
540///     // header_name = "Authorization"
541///     // header_value = "Bearer eyJhbGciOi..."
542/// }
543/// ```
544#[cfg(wasm)]
545pub fn auth_headers() -> Option<(&'static str, String)> {
546	get_jwt_token().map(|token| (AUTH_HEADER_NAME, format!("Bearer {}", token)))
547}
548
549/// Creates HTTP headers with JWT Bearer token (non-WASM stub).
550#[cfg(native)]
551pub fn auth_headers() -> Option<(&'static str, String)> {
552	None
553}
554
555/// Retrieves the JWT token from sessionStorage.
556///
557/// Returns `None` if no token is stored or if sessionStorage is unavailable.
558#[cfg(wasm)]
559pub fn get_jwt_token() -> Option<String> {
560	let window = web_sys::window()?;
561	let storage = window.session_storage().ok()??;
562	storage.get_item(JWT_STORAGE_KEY).ok()?
563}
564
565/// Retrieves the JWT token (non-WASM stub).
566#[cfg(native)]
567pub fn get_jwt_token() -> Option<String> {
568	None
569}
570
571/// Stores a JWT token in sessionStorage.
572///
573/// The token persists for the lifetime of the browser tab.
574#[cfg(wasm)]
575pub fn set_jwt_token(token: &str) {
576	if let Some(window) = web_sys::window()
577		&& let Ok(Some(storage)) = window.session_storage()
578	{
579		let _ = storage.set_item(JWT_STORAGE_KEY, token);
580	}
581}
582
583/// Stores a JWT token (non-WASM stub).
584#[cfg(native)]
585pub fn set_jwt_token(_token: &str) {
586	// No-op on non-WASM targets
587}
588
589/// Removes the JWT token from sessionStorage.
590///
591/// This should be called on logout or when a 401 response is received.
592#[cfg(wasm)]
593pub fn clear_jwt_token() {
594	if let Some(window) = web_sys::window()
595		&& let Ok(Some(storage)) = window.session_storage()
596	{
597		let _ = storage.remove_item(JWT_STORAGE_KEY);
598	}
599}
600
601/// Removes the JWT token (non-WASM stub).
602#[cfg(native)]
603pub fn clear_jwt_token() {
604	// No-op on non-WASM targets
605}
606
607#[cfg(test)]
608mod tests {
609	use super::*;
610
611	#[test]
612	fn test_auth_state_creation() {
613		let state = AuthState::new();
614		assert!(!state.is_authenticated());
615		assert!(state.user_id().is_none());
616		assert!(state.username().is_none());
617	}
618
619	#[test]
620	fn test_auth_state_login() {
621		let state = AuthState::new();
622		state.login("42", "testuser");
623
624		assert!(state.is_authenticated());
625		assert_eq!(state.user_id(), Some("42".to_string()));
626		assert_eq!(state.username(), Some("testuser".to_string()));
627	}
628
629	#[test]
630	fn test_auth_state_logout() {
631		let state = AuthState::new();
632		state.login("42", "testuser");
633		state.logout();
634
635		assert!(!state.is_authenticated());
636		assert!(state.user_id().is_none());
637		assert!(state.username().is_none());
638	}
639
640	#[test]
641	fn test_auth_state_from_server_data() {
642		let data = AuthData::full(
643			"1",
644			"admin",
645			Some("admin@example.com".to_string()),
646			true,
647			true,
648		);
649		let state = AuthState::from_server_data(data);
650
651		assert!(state.is_authenticated());
652		assert_eq!(state.user_id(), Some("1".to_string()));
653		assert_eq!(state.username(), Some("admin".to_string()));
654		assert_eq!(state.email(), Some("admin@example.com".to_string()));
655		assert!(state.is_staff());
656		assert!(state.is_superuser());
657	}
658
659	#[test]
660	fn test_auth_data_anonymous() {
661		let data = AuthData::anonymous();
662		assert!(!data.is_authenticated);
663		assert!(data.user_id.is_none());
664	}
665
666	#[test]
667	fn test_auth_data_authenticated() {
668		let data = AuthData::authenticated("1", "user");
669		assert!(data.is_authenticated);
670		assert_eq!(data.user_id, Some("1".to_string()));
671		assert_eq!(data.username, Some("user".to_string()));
672	}
673
674	#[test]
675	fn test_auth_state_update() {
676		let state = AuthState::new();
677		let data = AuthData::authenticated("99", "updated");
678		state.update(data);
679
680		assert!(state.is_authenticated());
681		assert_eq!(state.user_id(), Some("99".to_string()));
682		assert_eq!(state.username(), Some("updated".to_string()));
683	}
684
685	#[test]
686	fn test_global_auth_state() {
687		let state1 = auth_state();
688		let state2 = auth_state();
689
690		state1.login("1", "test");
691		assert!(state2.is_authenticated());
692	}
693
694	#[test]
695	fn test_auth_error_display() {
696		let network_err = AuthError::Network("timeout".to_string());
697		assert_eq!(network_err.to_string(), "Network error: timeout");
698
699		let server_err = AuthError::Server {
700			status: 401,
701			message: "Unauthorized".to_string(),
702		};
703		assert_eq!(server_err.to_string(), "Server error (401): Unauthorized");
704
705		let parse_err = AuthError::Parse("invalid json".to_string());
706		assert_eq!(parse_err.to_string(), "Parse error: invalid json");
707	}
708
709	#[test]
710	fn test_has_permission_with_cache() {
711		let state = AuthState::new();
712		let mut perms = HashSet::new();
713		perms.insert("blog.add_post".to_string());
714		perms.insert("blog.edit_post".to_string());
715		state.set_permissions(perms);
716
717		assert!(state.has_permission("blog.add_post"));
718		assert!(!state.has_permission("blog.delete_post"));
719	}
720
721	#[test]
722	fn test_superuser_has_all_permissions() {
723		let state = AuthState::new();
724		state.login_full("1", "admin", None, true, true);
725
726		assert!(state.has_permission("any.permission"));
727		assert!(state.has_permission("another.permission"));
728	}
729
730	#[test]
731	fn test_has_any_permission() {
732		let state = AuthState::new();
733		let mut perms = HashSet::new();
734		perms.insert("blog.view".to_string());
735		state.set_permissions(perms);
736
737		assert!(state.has_any_permission(&["blog.view", "blog.edit"]));
738		assert!(!state.has_any_permission(&["blog.delete", "blog.edit"]));
739	}
740
741	#[test]
742	fn test_has_all_permissions() {
743		let state = AuthState::new();
744		let mut perms = HashSet::new();
745		perms.insert("blog.view".to_string());
746		perms.insert("blog.edit".to_string());
747		state.set_permissions(perms);
748
749		assert!(state.has_all_permissions(&["blog.view", "blog.edit"]));
750		assert!(!state.has_all_permissions(&["blog.view", "blog.delete"]));
751	}
752
753	#[test]
754	fn test_permissions_cleared_on_logout() {
755		let state = AuthState::new();
756		let mut perms = HashSet::new();
757		perms.insert("blog.add_post".to_string());
758		state.set_permissions(perms);
759		state.login("1", "user");
760
761		state.logout();
762
763		assert!(!state.has_permission("blog.add_post"));
764		assert_eq!(state.permissions.get().len(), 0);
765	}
766
767	#[test]
768	fn test_permissions_from_auth_data() {
769		let data = AuthData {
770			is_authenticated: true,
771			user_id: Some("1".to_string()),
772			username: Some("user".to_string()),
773			email: None,
774			is_staff: false,
775			is_superuser: false,
776			permissions: vec!["blog.view".to_string(), "blog.edit".to_string()],
777		};
778		let state = AuthState::from_server_data(data);
779
780		assert!(state.has_permission("blog.view"));
781		assert!(state.has_permission("blog.edit"));
782		assert!(!state.has_permission("blog.delete"));
783	}
784
785	#[test]
786	fn test_permissions_update() {
787		let state = AuthState::new();
788		state.login("1", "user");
789
790		let data = AuthData {
791			is_authenticated: true,
792			user_id: Some("1".to_string()),
793			username: Some("user".to_string()),
794			email: None,
795			is_staff: false,
796			is_superuser: false,
797			permissions: vec!["blog.view".to_string()],
798		};
799		state.update(data);
800
801		assert!(state.has_permission("blog.view"));
802		assert!(!state.has_permission("blog.edit"));
803	}
804
805	#[test]
806	fn test_auth_headers_non_wasm() {
807		// On non-WASM targets, auth_headers always returns None
808		assert!(auth_headers().is_none());
809	}
810
811	#[test]
812	fn test_get_jwt_token_non_wasm() {
813		// On non-WASM targets, get_jwt_token always returns None
814		assert!(get_jwt_token().is_none());
815	}
816
817	#[test]
818	fn test_set_jwt_token_non_wasm() {
819		// On non-WASM targets, set_jwt_token is a no-op (should not panic)
820		set_jwt_token("test-token");
821	}
822
823	#[test]
824	fn test_clear_jwt_token_non_wasm() {
825		// On non-WASM targets, clear_jwt_token is a no-op (should not panic)
826		clear_jwt_token();
827	}
828
829	#[test]
830	fn test_jwt_constants() {
831		assert_eq!(AUTH_HEADER_NAME, "Authorization");
832		assert_eq!(JWT_STORAGE_KEY, "__admin_jwt");
833	}
834}