1use crate::lifecycle::NavigationAction;
43use crate::NavigationRequest;
44use gpui::App;
45
46pub trait RouteGuard: Send + Sync + 'static {
87 fn check(&self, cx: &App, request: &NavigationRequest) -> NavigationAction;
94
95 fn name(&self) -> &'static str {
97 "RouteGuard"
98 }
99
100 fn priority(&self) -> i32 {
102 0
103 }
104}
105
106pub 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
133pub 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
147pub type AuthCheckFn = Box<dyn Fn(&App) -> bool + Send + Sync>;
155
156pub struct AuthGuard {
170 check_fn: AuthCheckFn,
171 redirect_path: String,
172}
173
174impl AuthGuard {
175 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 #[cfg(debug_assertions)]
188 #[must_use]
189 pub fn allow_all() -> Self {
190 Self::new(|_| true, "/login")
191 }
192
193 #[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
218pub type RoleExtractorFn = Box<dyn Fn(&App) -> Option<String> + Send + Sync>;
226
227pub struct RoleGuard {
245 role_extractor: RoleExtractorFn,
246 required_role: String,
247 redirect_path: Option<String>,
248}
249
250impl RoleGuard {
251 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
293pub type PermissionCheckFn = Box<dyn Fn(&App, &str) -> bool + Send + Sync>;
302
303pub struct PermissionGuard {
320 check_fn: PermissionCheckFn,
321 permission: String,
322 redirect_path: Option<String>,
323}
324
325impl PermissionGuard {
326 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 #[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
369pub struct Guards {
391 guards: Vec<Box<dyn RouteGuard>>,
392}
393
394impl Guards {
395 #[must_use]
397 pub fn new(guards: Vec<Box<dyn RouteGuard>>) -> Self {
398 Self { guards }
399 }
400
401 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#[must_use]
432pub struct GuardBuilder {
433 guards: Vec<Box<dyn RouteGuard>>,
434}
435
436impl GuardBuilder {
437 pub fn new() -> Self {
439 Self { guards: Vec::new() }
440 }
441
442 pub fn guard<G: RouteGuard>(mut self, guard: G) -> Self {
444 self.guards.push(Box::new(guard));
445 self
446 }
447
448 #[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
461pub struct NotGuard {
480 guard: Box<dyn RouteGuard>,
481}
482
483impl NotGuard {
484 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#[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 #[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 #[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 #[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 #[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 #[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 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 #[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}