Skip to main content

gpui_navigator/
guards.rs

1//! Route guards for authentication, authorization, and validation.
2//!
3//! Guards are checked **before** navigation proceeds. They decide whether a
4//! navigation should be allowed, denied, or redirected elsewhere.
5//!
6//! All guard methods are **synchronous** --- GPUI is a single-threaded desktop
7//! framework and there is no need for async guard checks.
8//!
9//! # Built-in guards
10//!
11//! | Guard | Purpose |
12//! |-------|---------|
13//! | [`AuthGuard`] | Checks authentication via a user-provided function |
14//! | [`RoleGuard`] | Checks role-based authorization |
15//! | [`PermissionGuard`] | Checks specific permissions |
16//!
17//! # Composition
18//!
19//! | Combinator | Logic |
20//! |------------|-------|
21//! | [`Guards`] | AND — all guards must allow |
22//! | [`NotGuard`] | Invert — allow becomes deny, deny becomes allow |
23//!
24//! # Execution order
25//!
26//! Guards run in **priority order** (higher value first). The built-in guards
27//! use: `AuthGuard` = 100, `RoleGuard` = 90, `PermissionGuard` = 80.
28//! The first non-[`Continue`](crate::NavigationAction::Continue) result
29//! short-circuits evaluation.
30//!
31//! # Example
32//!
33//! ```no_run
34//! use gpui::IntoElement;
35//! use gpui_navigator::{Route, AuthGuard, RoleGuard, NavigationAction};
36//!
37//! Route::new("/admin", |_, _cx, _params| gpui::div().into_any_element())
38//!     .guard(AuthGuard::new(|_cx| true, "/login"))
39//!     .guard(RoleGuard::new(|_cx| Some("admin".into()), "admin", Some("/forbidden")));
40//! ```
41
42use crate::lifecycle::NavigationAction;
43use crate::NavigationRequest;
44use gpui::App;
45
46// ============================================================================
47// RouteGuard trait
48// ============================================================================
49
50/// Trait for route guards that control access to routes.
51///
52/// Guards are checked synchronously before navigation proceeds.
53///
54/// # Example
55///
56/// ```no_run
57/// use gpui_navigator::{RouteGuard, NavigationAction, NavigationRequest};
58///
59/// struct MyAuthGuard {
60///     redirect_to: String,
61/// }
62///
63/// impl RouteGuard for MyAuthGuard {
64///     fn check(&self, _cx: &gpui::App, _request: &NavigationRequest) -> NavigationAction {
65///         let is_authenticated = true; // Replace with actual check
66///         if is_authenticated {
67///             NavigationAction::Continue
68///         } else {
69///             NavigationAction::redirect(&self.redirect_to)
70///         }
71///     }
72/// }
73/// ```
74///
75/// # For simple guards
76///
77/// Use [`guard_fn`] to create a guard from a closure:
78///
79/// ```no_run
80/// use gpui_navigator::{guard_fn, NavigationAction};
81///
82/// let guard = guard_fn(|_cx, _request| {
83///     NavigationAction::Continue
84/// });
85/// ```
86pub trait RouteGuard: Send + Sync + 'static {
87    /// Check if navigation should be allowed.
88    ///
89    /// Returns:
90    /// - [`NavigationAction::Continue`] to allow navigation
91    /// - [`NavigationAction::Deny`] to block navigation
92    /// - [`NavigationAction::Redirect`] to redirect to a different path
93    fn check(&self, cx: &App, request: &NavigationRequest) -> NavigationAction;
94
95    /// Guard name for debugging and error messages.
96    fn name(&self) -> &'static str {
97        "RouteGuard"
98    }
99
100    /// Priority for execution order. Higher runs first. Default is 0.
101    fn priority(&self) -> i32 {
102        0
103    }
104}
105
106// ============================================================================
107// guard_fn helper
108// ============================================================================
109
110/// Create a guard from a function or closure.
111///
112/// # Example
113///
114/// ```no_run
115/// use gpui_navigator::{guard_fn, NavigationAction};
116///
117/// let auth_guard = guard_fn(|_cx, _request| {
118///     let is_authenticated = true; // Replace with actual check
119///     if is_authenticated {
120///         NavigationAction::Continue
121///     } else {
122///         NavigationAction::redirect("/login")
123///     }
124/// });
125/// ```
126pub const fn guard_fn<F>(f: F) -> FnGuard<F>
127where
128    F: Fn(&App, &NavigationRequest) -> NavigationAction + Send + Sync + 'static,
129{
130    FnGuard { f }
131}
132
133/// Guard created from a function or closure.
134pub struct FnGuard<F> {
135    f: F,
136}
137
138impl<F> RouteGuard for FnGuard<F>
139where
140    F: Fn(&App, &NavigationRequest) -> NavigationAction + Send + Sync + 'static,
141{
142    fn check(&self, cx: &App, request: &NavigationRequest) -> NavigationAction {
143        (self.f)(cx, request)
144    }
145}
146
147// ============================================================================
148// AuthGuard
149// ============================================================================
150
151/// Function type for authentication checks.
152///
153/// Receives the app context and returns `true` if the user is authenticated.
154pub type AuthCheckFn = Box<dyn Fn(&App) -> bool + Send + Sync>;
155
156/// Authentication guard that checks if user is logged in.
157///
158/// # Example
159///
160/// ```no_run
161/// use gpui::IntoElement;
162/// use gpui_navigator::{Route, AuthGuard};
163///
164/// Route::new("/dashboard", |_, _cx, _params| gpui::div().into_any_element())
165///     .guard(AuthGuard::new(|_cx| {
166///         true // Replace with actual auth check
167///     }, "/login"));
168/// ```
169pub struct AuthGuard {
170    check_fn: AuthCheckFn,
171    redirect_path: String,
172}
173
174impl AuthGuard {
175    /// Create a new auth guard with a custom check function and redirect path.
176    pub fn new<F>(check_fn: F, redirect_path: impl Into<String>) -> Self
177    where
178        F: Fn(&App) -> bool + Send + Sync + 'static,
179    {
180        Self {
181            check_fn: Box::new(check_fn),
182            redirect_path: redirect_path.into(),
183        }
184    }
185
186    /// Create an auth guard that always allows access (for testing/development).
187    #[cfg(debug_assertions)]
188    #[must_use]
189    pub fn allow_all() -> Self {
190        Self::new(|_| true, "/login")
191    }
192
193    /// Create an auth guard that always denies access (for testing/development).
194    #[cfg(debug_assertions)]
195    pub fn deny_all(redirect_path: impl Into<String>) -> Self {
196        Self::new(|_| false, redirect_path)
197    }
198}
199
200impl RouteGuard for AuthGuard {
201    fn check(&self, cx: &App, _request: &NavigationRequest) -> NavigationAction {
202        if (self.check_fn)(cx) {
203            NavigationAction::Continue
204        } else {
205            NavigationAction::redirect_with_reason(&self.redirect_path, "Authentication required")
206        }
207    }
208
209    fn name(&self) -> &'static str {
210        "AuthGuard"
211    }
212
213    fn priority(&self) -> i32 {
214        100
215    }
216}
217
218// ============================================================================
219// RoleGuard
220// ============================================================================
221
222/// Function type for extracting the current user's role.
223///
224/// Returns `None` if the user has no role or is not authenticated.
225pub type RoleExtractorFn = Box<dyn Fn(&App) -> Option<String> + Send + Sync>;
226
227/// Role-based authorization guard.
228///
229/// Checks if user has required role for accessing a route.
230///
231/// # Example
232///
233/// ```no_run
234/// use gpui::IntoElement;
235/// use gpui_navigator::{Route, RoleGuard};
236///
237/// Route::new("/admin", |_, _cx, _params| gpui::div().into_any_element())
238///     .guard(RoleGuard::new(
239///         |_cx| Some("admin".into()),
240///         "admin",
241///         Some("/forbidden"),
242///     ));
243/// ```
244pub struct RoleGuard {
245    role_extractor: RoleExtractorFn,
246    required_role: String,
247    redirect_path: Option<String>,
248}
249
250impl RoleGuard {
251    /// Create a new role guard with a role extractor function.
252    pub fn new<F>(
253        role_extractor: F,
254        required_role: impl Into<String>,
255        redirect_path: Option<impl Into<String>>,
256    ) -> Self
257    where
258        F: Fn(&App) -> Option<String> + Send + Sync + 'static,
259    {
260        Self {
261            role_extractor: Box::new(role_extractor),
262            required_role: required_role.into(),
263            redirect_path: redirect_path.map(Into::into),
264        }
265    }
266}
267
268impl RouteGuard for RoleGuard {
269    fn check(&self, cx: &App, _request: &NavigationRequest) -> NavigationAction {
270        let has_role = (self.role_extractor)(cx).is_some_and(|role| role == self.required_role);
271
272        if has_role {
273            NavigationAction::Continue
274        } else if let Some(redirect) = &self.redirect_path {
275            NavigationAction::redirect_with_reason(
276                redirect,
277                format!("Requires '{}' role", self.required_role),
278            )
279        } else {
280            NavigationAction::deny(format!("Missing required role: {}", self.required_role))
281        }
282    }
283
284    fn name(&self) -> &'static str {
285        "RoleGuard"
286    }
287
288    fn priority(&self) -> i32 {
289        90
290    }
291}
292
293// ============================================================================
294// PermissionGuard
295// ============================================================================
296
297/// Function type for checking a specific permission.
298///
299/// Receives the app context and the permission string to check.
300/// Returns `true` if the user holds the requested permission.
301pub type PermissionCheckFn = Box<dyn Fn(&App, &str) -> bool + Send + Sync>;
302
303/// Permission-based authorization guard.
304///
305/// Checks if user has a specific permission.
306///
307/// # Example
308///
309/// ```no_run
310/// use gpui::IntoElement;
311/// use gpui_navigator::{Route, PermissionGuard};
312///
313/// Route::new("/users/:id/delete", |_, _cx, _params| gpui::div().into_any_element())
314///     .guard(PermissionGuard::new(
315///         |_cx, _perm| true, // Replace with actual permission check
316///         "users.delete",
317///     ));
318/// ```
319pub struct PermissionGuard {
320    check_fn: PermissionCheckFn,
321    permission: String,
322    redirect_path: Option<String>,
323}
324
325impl PermissionGuard {
326    /// Create a new permission guard with a check function.
327    pub fn new<F>(check_fn: F, permission: impl Into<String>) -> Self
328    where
329        F: Fn(&App, &str) -> bool + Send + Sync + 'static,
330    {
331        Self {
332            check_fn: Box::new(check_fn),
333            permission: permission.into(),
334            redirect_path: None,
335        }
336    }
337
338    /// Add a redirect path for when permission is denied.
339    #[must_use]
340    pub fn with_redirect(mut self, path: impl Into<String>) -> Self {
341        self.redirect_path = Some(path.into());
342        self
343    }
344}
345
346impl RouteGuard for PermissionGuard {
347    fn check(&self, cx: &App, _request: &NavigationRequest) -> NavigationAction {
348        if (self.check_fn)(cx, &self.permission) {
349            NavigationAction::Continue
350        } else if let Some(redirect) = &self.redirect_path {
351            NavigationAction::redirect_with_reason(
352                redirect,
353                format!("Missing permission: {}", self.permission),
354            )
355        } else {
356            NavigationAction::deny(format!("Missing permission: {}", self.permission))
357        }
358    }
359
360    fn name(&self) -> &'static str {
361        "PermissionGuard"
362    }
363
364    fn priority(&self) -> i32 {
365        80
366    }
367}
368
369// ============================================================================
370// Guard Composition
371// ============================================================================
372
373/// Combines multiple guards with AND logic.
374///
375/// All guards must return [`NavigationAction::Continue`] for navigation to proceed.
376/// The first non-continue result is returned immediately (short-circuit).
377///
378/// Guards are executed in priority order (higher priority first).
379///
380/// # Example
381///
382/// ```no_run
383/// use gpui_navigator::{Guards, AuthGuard, RoleGuard};
384///
385/// let guard = Guards::builder()
386///     .guard(AuthGuard::new(|_| true, "/login"))
387///     .guard(RoleGuard::new(|_| Some("admin".into()), "admin", None::<&str>))
388///     .build();
389/// ```
390pub struct Guards {
391    guards: Vec<Box<dyn RouteGuard>>,
392}
393
394impl Guards {
395    /// Create a new AND composition from a vec of boxed guards.
396    #[must_use]
397    pub fn new(guards: Vec<Box<dyn RouteGuard>>) -> Self {
398        Self { guards }
399    }
400
401    /// Start building a guard composition.
402    pub fn builder() -> GuardBuilder {
403        GuardBuilder::new()
404    }
405}
406
407impl RouteGuard for Guards {
408    fn check(&self, cx: &App, request: &NavigationRequest) -> NavigationAction {
409        let mut sorted: Vec<_> = self.guards.iter().collect();
410        sorted.sort_by_key(|g| std::cmp::Reverse(g.priority()));
411
412        for guard in sorted {
413            let result = guard.check(cx, request);
414            if !matches!(result, NavigationAction::Continue) {
415                return result;
416            }
417        }
418        NavigationAction::Continue
419    }
420
421    fn name(&self) -> &'static str {
422        "Guards"
423    }
424
425    fn priority(&self) -> i32 {
426        self.guards.iter().map(|g| g.priority()).max().unwrap_or(0)
427    }
428}
429
430/// Builder for [`Guards`] with fluent API.
431#[must_use]
432pub struct GuardBuilder {
433    guards: Vec<Box<dyn RouteGuard>>,
434}
435
436impl GuardBuilder {
437    /// Create a new builder.
438    pub fn new() -> Self {
439        Self { guards: Vec::new() }
440    }
441
442    /// Add a guard to the composition.
443    pub fn guard<G: RouteGuard>(mut self, guard: G) -> Self {
444        self.guards.push(Box::new(guard));
445        self
446    }
447
448    /// Build the final [`Guards`].
449    #[must_use]
450    pub fn build(self) -> Guards {
451        Guards::new(self.guards)
452    }
453}
454
455impl Default for GuardBuilder {
456    fn default() -> Self {
457        Self::new()
458    }
459}
460
461// ============================================================================
462// NotGuard
463// ============================================================================
464
465/// Inverts a guard result.
466///
467/// - `Continue` becomes `Deny`
468/// - `Deny` becomes `Continue`
469/// - `Redirect` is preserved as-is
470///
471/// # Example
472///
473/// ```no_run
474/// use gpui_navigator::{NotGuard, AuthGuard};
475///
476/// // Allow only if NOT authenticated (e.g. for a login page)
477/// let guard = NotGuard::new(AuthGuard::new(|_| true, "/login"));
478/// ```
479pub struct NotGuard {
480    guard: Box<dyn RouteGuard>,
481}
482
483impl NotGuard {
484    /// Create a new NOT guard wrapping the given guard.
485    pub fn new<G: RouteGuard>(guard: G) -> Self {
486        Self {
487            guard: Box::new(guard),
488        }
489    }
490}
491
492impl RouteGuard for NotGuard {
493    fn check(&self, cx: &App, request: &NavigationRequest) -> NavigationAction {
494        match self.guard.check(cx, request) {
495            NavigationAction::Continue => {
496                NavigationAction::deny("Inverted: guard allowed but NOT expected")
497            }
498            NavigationAction::Deny { .. } => NavigationAction::Continue,
499            redirect @ NavigationAction::Redirect { .. } => redirect,
500        }
501    }
502
503    fn name(&self) -> &'static str {
504        "NotGuard"
505    }
506
507    fn priority(&self) -> i32 {
508        self.guard.priority()
509    }
510}
511
512// ============================================================================
513// Tests
514// ============================================================================
515
516#[cfg(test)]
517#[allow(clippy::needless_pass_by_ref_mut)]
518mod tests {
519    use super::*;
520
521    fn make_request(path: &str) -> NavigationRequest {
522        NavigationRequest::new(path.to_string())
523    }
524
525    // --- RouteGuard trait basics ---
526
527    #[test]
528    fn test_guard_fn_helper() {
529        let guard = guard_fn(|_cx, _req| NavigationAction::Continue);
530        assert_eq!(guard.name(), "RouteGuard");
531        assert_eq!(guard.priority(), 0);
532    }
533
534    // --- AuthGuard ---
535
536    #[gpui::test]
537    fn test_auth_guard_allows_authenticated(cx: &mut gpui::TestAppContext) {
538        let guard = AuthGuard::new(|_| true, "/login");
539        assert_eq!(guard.name(), "AuthGuard");
540        assert_eq!(guard.priority(), 100);
541
542        let request = make_request("/dashboard");
543        let result = cx.update(|cx| guard.check(cx, &request));
544        assert!(result.is_continue());
545    }
546
547    #[gpui::test]
548    fn test_auth_guard_redirects_unauthenticated(cx: &mut gpui::TestAppContext) {
549        let guard = AuthGuard::new(|_| false, "/login");
550        let request = make_request("/dashboard");
551        let result = cx.update(|cx| guard.check(cx, &request));
552
553        assert!(result.is_redirect());
554        assert_eq!(result.redirect_path(), Some("/login"));
555    }
556
557    // --- RoleGuard ---
558
559    #[gpui::test]
560    fn test_role_guard_allows_correct_role(cx: &mut gpui::TestAppContext) {
561        let guard = RoleGuard::new(|_| Some("admin".to_string()), "admin", None::<String>);
562        assert_eq!(guard.name(), "RoleGuard");
563        assert_eq!(guard.priority(), 90);
564
565        let request = make_request("/admin");
566        let result = cx.update(|cx| guard.check(cx, &request));
567        assert!(result.is_continue());
568    }
569
570    #[gpui::test]
571    fn test_role_guard_with_redirect(cx: &mut gpui::TestAppContext) {
572        let guard = RoleGuard::new(|_| Some("user".to_string()), "admin", Some("/403"));
573        let request = make_request("/admin");
574        let result = cx.update(|cx| guard.check(cx, &request));
575
576        assert!(result.is_redirect());
577        assert_eq!(result.redirect_path(), Some("/403"));
578    }
579
580    #[gpui::test]
581    fn test_role_guard_deny_without_redirect(cx: &mut gpui::TestAppContext) {
582        let guard = RoleGuard::new(|_| None, "admin", None::<String>);
583        let request = make_request("/admin");
584        let result = cx.update(|cx| guard.check(cx, &request));
585        assert!(result.is_deny());
586    }
587
588    // --- PermissionGuard ---
589
590    #[gpui::test]
591    fn test_permission_guard_allows(cx: &mut gpui::TestAppContext) {
592        let guard = PermissionGuard::new(|_, _| true, "users.delete");
593        assert_eq!(guard.name(), "PermissionGuard");
594
595        let request = make_request("/users/123/delete");
596        let result = cx.update(|cx| guard.check(cx, &request));
597        assert!(result.is_continue());
598    }
599
600    #[gpui::test]
601    fn test_permission_guard_denies(cx: &mut gpui::TestAppContext) {
602        let guard = PermissionGuard::new(|_, _| false, "users.delete");
603        let request = make_request("/users/123/delete");
604        let result = cx.update(|cx| guard.check(cx, &request));
605        assert!(result.is_deny());
606    }
607
608    #[gpui::test]
609    fn test_permission_guard_with_redirect(cx: &mut gpui::TestAppContext) {
610        let guard = PermissionGuard::new(|_, _| false, "users.delete").with_redirect("/forbidden");
611        let request = make_request("/users/123/delete");
612        let result = cx.update(|cx| guard.check(cx, &request));
613
614        assert!(result.is_redirect());
615        assert_eq!(result.redirect_path(), Some("/forbidden"));
616    }
617
618    // --- Guards composition ---
619
620    #[gpui::test]
621    fn test_guards_all_pass(cx: &mut gpui::TestAppContext) {
622        let guards = Guards::builder()
623            .guard(AuthGuard::new(|_| true, "/login"))
624            .guard(RoleGuard::new(
625                |_| Some("admin".to_string()),
626                "admin",
627                None::<String>,
628            ))
629            .build();
630
631        let request = make_request("/admin");
632        let result = cx.update(|cx| guards.check(cx, &request));
633        assert!(result.is_continue());
634    }
635
636    #[gpui::test]
637    fn test_guards_one_fails(cx: &mut gpui::TestAppContext) {
638        let guards = Guards::builder()
639            .guard(AuthGuard::new(|_| true, "/login"))
640            .guard(RoleGuard::new(|_| None, "admin", Some("/forbidden")))
641            .build();
642
643        let request = make_request("/admin");
644        let result = cx.update(|cx| guards.check(cx, &request));
645        assert!(result.is_redirect());
646        assert_eq!(result.redirect_path(), Some("/forbidden"));
647    }
648
649    #[gpui::test]
650    fn test_guards_priority_order(cx: &mut gpui::TestAppContext) {
651        // Auth (priority 100) should run before Role (priority 90)
652        // If auth fails, we should get auth's redirect, not role's
653        let guards = Guards::builder()
654            .guard(RoleGuard::new(|_| None, "admin", Some("/role-denied")))
655            .guard(AuthGuard::new(|_| false, "/auth-denied"))
656            .build();
657
658        let request = make_request("/admin");
659        let result = cx.update(|cx| guards.check(cx, &request));
660        assert_eq!(result.redirect_path(), Some("/auth-denied"));
661    }
662
663    // --- NotGuard ---
664
665    #[gpui::test]
666    fn test_not_guard_inverts_allow(cx: &mut gpui::TestAppContext) {
667        let guard = NotGuard::new(guard_fn(|_, _| NavigationAction::Continue));
668        let request = make_request("/test");
669        let result = cx.update(|cx| guard.check(cx, &request));
670        assert!(result.is_deny());
671    }
672
673    #[gpui::test]
674    fn test_not_guard_inverts_deny(cx: &mut gpui::TestAppContext) {
675        let guard = NotGuard::new(guard_fn(|_, _| NavigationAction::deny("nope")));
676        let request = make_request("/test");
677        let result = cx.update(|cx| guard.check(cx, &request));
678        assert!(result.is_continue());
679    }
680
681    #[gpui::test]
682    fn test_not_guard_preserves_redirect(cx: &mut gpui::TestAppContext) {
683        let guard = NotGuard::new(guard_fn(|_, _| NavigationAction::redirect("/somewhere")));
684        let request = make_request("/test");
685        let result = cx.update(|cx| guard.check(cx, &request));
686        assert!(result.is_redirect());
687        assert_eq!(result.redirect_path(), Some("/somewhere"));
688    }
689}