app.rs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. //! Application shell: skip link, header, router, footer.
  2. use leptos::prelude::*;
  3. use leptos_router::components::{A, Redirect, Route, Router, Routes};
  4. use leptos_router::path;
  5. use web::model::SessionProbe;
  6. use web::viewmodel::{NAV_ITEMS, PRODUCT_NAME, dashboard_nav_visible};
  7. use super::components::dashboard::Dashboard;
  8. use super::components::deployment::ConfigResource;
  9. use super::components::feedback::SessionSkeleton;
  10. use super::components::icons::{Icon, IconKind};
  11. use super::components::landing::Landing;
  12. use super::components::theme::ThemeToggle;
  13. /// Root component, mounted to `<body>` by `main.rs`.
  14. #[component]
  15. pub fn App() -> impl IntoView {
  16. // One app-level session probe, shared with the header and the `/dashboard`
  17. // guard so navigation gating and route guarding read a single source of
  18. // truth. `LocalResource` is `Copy`, so context provision is cheap.
  19. let session = LocalResource::new(|| async { crate::api::probe_session().await });
  20. provide_context(session);
  21. // One app-level config resource, shared with the landing CTA and the
  22. // dashboard deployment card so both read a single `/api/config` fetch.
  23. let config: ConfigResource = LocalResource::new(|| async { crate::api::fetch_config().await });
  24. provide_context(config);
  25. view! {
  26. <Router>
  27. <div class="flex min-h-dvh flex-col bg-canvas text-ink">
  28. <a
  29. href="#main"
  30. class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:bg-raised focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg"
  31. >
  32. "Skip to content"
  33. </a>
  34. <header class="sticky top-0 z-20 border-b border-line/70 bg-canvas/85 backdrop-blur-md">
  35. <div class="mx-auto flex h-16 w-full max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
  36. <A href="/" attr:class="group flex items-center gap-2.5 rounded-lg">
  37. <span
  38. aria-hidden="true"
  39. class="grid size-8 place-items-center rounded-lg bg-btn text-btn-ink shadow-sm transition-colors group-hover:bg-btn-hover"
  40. >
  41. <Icon kind=IconKind::Shield class="size-4" />
  42. </span>
  43. <span class="text-sm font-semibold tracking-tight">{PRODUCT_NAME}</span>
  44. </A>
  45. <div class="flex items-center gap-2">
  46. <PrimaryNav />
  47. <ThemeToggle />
  48. </div>
  49. </div>
  50. </header>
  51. <main
  52. id="main"
  53. tabindex="-1"
  54. class="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 sm:py-14"
  55. >
  56. <Routes fallback=|| view! { <NotFound /> }>
  57. <Route path=path!("/") view=Landing />
  58. <Route path=path!("/dashboard") view=DashboardRoute />
  59. </Routes>
  60. </main>
  61. <footer class="border-t border-line">
  62. <div class="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-2 px-4 py-5 text-xs text-muted sm:px-6">
  63. <span>"OAuth 2.1 · PKCE S256 · DPoP · PAR · opaque tokens"</span>
  64. <span>"Secrets stay on the server"</span>
  65. </div>
  66. </footer>
  67. </div>
  68. </Router>
  69. }
  70. }
  71. /// Header navigation: `Landing` is always rendered; `Dashboard` appears only
  72. /// once the shared probe reports [`SessionProbe::Active`].
  73. #[component]
  74. fn PrimaryNav() -> impl IntoView {
  75. let session = use_context::<LocalResource<SessionProbe>>();
  76. let dashboard_visible = Signal::derive(move || {
  77. session
  78. .and_then(|resource| resource.get())
  79. .is_some_and(|probe| dashboard_nav_visible(&probe))
  80. });
  81. view! {
  82. <nav aria-label="Primary" class="flex items-center gap-1 text-sm">
  83. {NAV_ITEMS
  84. .iter()
  85. .map(|(label, href)| {
  86. let visible = if *href == "/dashboard" {
  87. dashboard_visible
  88. } else {
  89. Signal::derive(|| true)
  90. };
  91. view! {
  92. <Show when=move || visible.get()>
  93. <A
  94. href=*href
  95. attr:class="rounded-md px-3 py-1.5 text-muted transition-colors hover:bg-sunken hover:text-ink"
  96. >
  97. {*label}
  98. </A>
  99. </Show>
  100. }
  101. })
  102. .collect_view()}
  103. </nav>
  104. }
  105. }
  106. /// `/dashboard` guard: an active session renders the dashboard, a definitive
  107. /// [`SessionProbe::Unauthenticated`] redirects to the landing page, and
  108. /// loading/error states keep rendering the dashboard's own skeleton and typed
  109. /// error cards so a transient BFF failure is never misread as logged-out.
  110. #[component]
  111. fn DashboardRoute() -> impl IntoView {
  112. let session = use_context::<LocalResource<SessionProbe>>();
  113. // Coarse memo: `Active(_)` -> `Active(_)` refetches must not remount the
  114. // dashboard, or its refresh/log-out state would reset underneath the user.
  115. let redirect_home = Memo::new(move |_| {
  116. session.is_some_and(|resource| matches!(resource.get(), Some(SessionProbe::Unauthenticated)))
  117. });
  118. view! {
  119. {move || {
  120. let Some(resource) = session else {
  121. return view! { <SessionSkeleton /> }.into_any();
  122. };
  123. if redirect_home.get() {
  124. view! { <Redirect path="/" /> }.into_any()
  125. } else {
  126. view! { <Dashboard probe=resource /> }.into_any()
  127. }
  128. }}
  129. }
  130. }
  131. /// Fallback for unmatched routes.
  132. #[component]
  133. fn NotFound() -> impl IntoView {
  134. view! {
  135. <section class="mx-auto max-w-md py-24 text-center">
  136. <p class=super::components::styles::EYEBROW>"404"</p>
  137. <h1 class="mt-2 text-title font-semibold text-ink">"Page not found"</h1>
  138. <p class="mt-3 text-sm leading-6 text-muted">
  139. "That route does not exist in this client."
  140. </p>
  141. <A
  142. href="/"
  143. attr:class="mt-7 inline-flex rounded-lg bg-btn px-4 py-2 text-sm font-semibold text-btn-ink transition-colors hover:bg-btn-hover"
  144. >
  145. "Back to landing"
  146. </A>
  147. </section>
  148. }
  149. }