Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: better settings frontend gating

Hugo 9b492a85 bdc756be

+87 -57
+5 -16
packages/app/src/app.tsx
··· 4 4 import { useLocation, useRoute } from "@exosphere/client/router"; 5 5 import { spherePath } from "@exosphere/client/router"; 6 6 import { sphereState, sphereHandle, loadSphere } from "@exosphere/client/sphere"; 7 + import { canAccessSettings } from "@exosphere/client/permissions"; 7 8 import { isMultiSphere } from "@exosphere/client/config"; 8 9 import { Link } from "@exosphere/client/link"; 9 10 import * as ui from "@exosphere/client/ui.css"; ··· 18 19 import { SpherePermissionsPage } from "./pages/sphere-permissions.tsx"; 19 20 import { SphereLabelsPage } from "./pages/sphere-labels.tsx"; 20 21 import { Dashboard } from "./pages/dashboard.tsx"; 22 + import { NotFoundPage } from "./pages/not-found.tsx"; 21 23 import type { ModuleRoute } from "@exosphere/client/types"; 22 24 import { feedsModule } from "@exosphere/feeds/client"; 23 25 import { featureRequestsModule } from "@exosphere/feature-requests/client"; ··· 73 75 /** Reactive default page for multi-sphere mode — public landing/dashboard. */ 74 76 function MultiSphereDefaultPage() { 75 77 return <Dashboard />; 76 - } 77 - 78 - function NotFoundPage() { 79 - return ( 80 - <div class={ui.container}> 81 - <div class={ui.section}> 82 - <h1 class={ui.pageTitle}>Page not found</h1> 83 - <p class={ui.description}>The page you're looking for doesn't exist.</p> 84 - <a href={spherePath("/")} class={ui.button}> 85 - Go home 86 - </a> 87 - </div> 88 - </div> 89 - ); 90 78 } 91 79 92 80 /** Watches the :sphereHandle route param and reloads sphere data when it changes. */ ··· 290 278 291 279 const homeHref = isMultiSphere && sphere ? spherePath("/") : "/"; 292 280 const showSphereNav = Boolean(sphere); 281 + const showSettings = authenticated && showSphereNav && canAccessSettings(); 293 282 const settingsActive = isSettingsPathActive(path); 294 283 295 284 const closeDrawer = () => setDrawerOpen(false); ··· 313 302 </span> 314 303 )} 315 304 <ThemeToggle icons={{ light: Sun, dark: Moon, system: Monitor }} /> 316 - {showSphereNav && ( 305 + {showSettings && ( 317 306 <Link 318 307 href={spherePath("/settings")} 319 308 class={`${h.headerAction} ${settingsActive ? h.headerActionActive : ""}`} ··· 363 352 <ThemeToggle icons={{ light: Sun, dark: Moon, system: Monitor }} /> 364 353 </div> 365 354 <div class={h.drawerActionRow}> 366 - {showSphereNav && ( 355 + {showSettings && ( 367 356 <Link 368 357 href={spherePath("/settings")} 369 358 class={`${ui.buttonSecondary} ${h.drawerAction}`}
+16
packages/app/src/pages/not-found.tsx
··· 1 + import { spherePath } from "@exosphere/client/router"; 2 + import * as ui from "@exosphere/client/ui.css"; 3 + 4 + export function NotFoundPage() { 5 + return ( 6 + <div class={ui.container}> 7 + <div class={ui.section}> 8 + <h1 class={ui.pageTitle}>Page not found</h1> 9 + <p class={ui.description}>The page you're looking for doesn't exist.</p> 10 + <a href={spherePath("/")} class={ui.button}> 11 + Go home 12 + </a> 13 + </div> 14 + </div> 15 + ); 16 + }
+30 -27
packages/app/src/pages/settings-layout.tsx
··· 1 1 import type { ComponentChildren } from "preact"; 2 - import { useLocation } from "@exosphere/client/router"; 3 - import { spherePath } from "@exosphere/client/router"; 2 + import { useLocation, spherePath } from "@exosphere/client/router"; 4 3 import { sphereState } from "@exosphere/client/sphere"; 5 - import { canDo } from "@exosphere/client/permissions"; 4 + import { canDo, canManageMembers, canManageModules } from "@exosphere/client/permissions"; 6 5 import { Link } from "@exosphere/client/link"; 7 6 import * as s from "./sphere.css.ts"; 8 7 import * as ss from "./sphere-settings.css.ts"; 9 8 10 - // "members" and "permissions" stay in the union because their pages still pass 11 - // them as `active`, even though the rail doesn't render links to them anymore. 12 - // The pages remain reachable by direct URL. 13 9 export type SettingsSection = "general" | "modules" | "labels" | "members" | "permissions"; 14 10 15 11 interface Props { ··· 17 13 children: ComponentChildren; 18 14 } 19 15 20 - function RailLink({ 21 - href, 22 - active, 23 - label, 24 - danger, 25 - }: { 26 - href: string; 27 - active: boolean; 28 - label: string; 29 - danger?: boolean; 30 - }) { 31 - const classes = [ss.railLink, active ? ss.railLinkActive : "", danger ? ss.railLinkDanger : ""] 32 - .filter(Boolean) 33 - .join(" "); 16 + function RailLink({ href, active, label }: { href: string; active: boolean; label: string }) { 17 + const classes = `${ss.railLink}${active ? ` ${ss.railLinkActive}` : ""}`; 34 18 return ( 35 19 <Link href={href} class={classes}> 36 20 {label} ··· 43 27 useLocation(); 44 28 45 29 const data = sphereState.value.data; 46 - const canManageLabels = canDo("sphere", "manageLabels"); 30 + const showModules = canManageModules(); 31 + const showLabels = canDo("sphere", "manageLabels"); 32 + const showMembers = canManageMembers(); 33 + const showPermissions = canDo("sphere", "updatePermissions"); 47 34 48 35 return ( 49 36 <div class={s.spherePage}> ··· 59 46 <aside class={ss.rail} aria-label="Settings sections"> 60 47 <div class={ss.railGroup}>Sphere</div> 61 48 <RailLink href={spherePath("/settings")} active={active === "general"} label="General" /> 62 - <RailLink 63 - href={spherePath("/settings/modules")} 64 - active={active === "modules"} 65 - label="Modules" 66 - /> 67 - {canManageLabels && ( 49 + {showModules && ( 50 + <RailLink 51 + href={spherePath("/settings/modules")} 52 + active={active === "modules"} 53 + label="Modules" 54 + /> 55 + )} 56 + {showLabels && ( 68 57 <RailLink 69 58 href={spherePath("/settings/labels")} 70 59 active={active === "labels"} 71 60 label="Labels" 61 + /> 62 + )} 63 + {showMembers && ( 64 + <RailLink 65 + href={spherePath("/settings/members")} 66 + active={active === "members"} 67 + label="Members" 68 + /> 69 + )} 70 + {showPermissions && ( 71 + <RailLink 72 + href={spherePath("/settings/permissions")} 73 + active={active === "permissions"} 74 + label="Permissions" 72 75 /> 73 76 )} 74 77 </aside>
+2 -1
packages/app/src/pages/sphere-labels.tsx
··· 8 8 import * as ui from "@exosphere/client/ui.css"; 9 9 import * as cpUi from "./color-picker.css.ts"; 10 10 import { SettingsLayout } from "./settings-layout.tsx"; 11 + import { NotFoundPage } from "./not-found.tsx"; 11 12 import { 12 13 getSphereLabels, 13 14 createSphereLabel, ··· 33 34 const handle = sphereHandle.value; 34 35 35 36 if (!data || !handle) return null; 36 - if (!canDo("sphere", "manageLabels")) return null; 37 + if (!canDo("sphere", "manageLabels")) return <NotFoundPage />; 37 38 38 39 return ( 39 40 <SettingsLayout active="labels">
+3 -7
packages/app/src/pages/sphere-members.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import { sphereState, sphereHandle } from "@exosphere/client/sphere"; 3 - import { canDo } from "@exosphere/client/permissions"; 3 + import { canDo, canManageMembers } from "@exosphere/client/permissions"; 4 4 import { useQuery } from "@exosphere/client/hooks"; 5 5 import * as ui from "@exosphere/client/ui.css"; 6 6 import { SettingsLayout } from "./settings-layout.tsx"; 7 + import { NotFoundPage } from "./not-found.tsx"; 7 8 import { 8 9 getSphereMembers, 9 10 inviteMember, ··· 29 30 const handle = sphereHandle.value; 30 31 31 32 if (!data || !handle) return null; 32 - // Allow access if user has any member management permission 33 - const canManageMembers = 34 - canDo("sphere", "inviteMember") || 35 - canDo("sphere", "revokeMember") || 36 - canDo("sphere", "updateMemberRole"); 37 - if (!canManageMembers) return null; 33 + if (!canManageMembers()) return <NotFoundPage />; 38 34 39 35 return ( 40 36 <SettingsLayout active="members">
+2 -1
packages/app/src/pages/sphere-permissions.tsx
··· 4 4 import { useQuery } from "@exosphere/client/hooks"; 5 5 import * as ui from "@exosphere/client/ui.css"; 6 6 import { SettingsLayout } from "./settings-layout.tsx"; 7 + import { NotFoundPage } from "./not-found.tsx"; 7 8 import { getSpherePermissions, updateSpherePermissions } from "../api/spheres.ts"; 8 9 9 10 const roleLabels: Record<string, string> = { ··· 26 27 const handle = sphereHandle.value; 27 28 28 29 if (!data || !handle) return null; 29 - if (!canDo("sphere", "updatePermissions")) return null; 30 + if (!canDo("sphere", "updatePermissions")) return <NotFoundPage />; 30 31 31 32 return ( 32 33 <SettingsLayout active="permissions">
-4
packages/app/src/pages/sphere-settings.css.ts
··· 70 70 color: vars.color.primary, 71 71 }); 72 72 73 - export const railLinkDanger = style({ 74 - color: vars.color.danger, 75 - }); 76 - 77 73 export const railGroup = style({ 78 74 paddingBlock: "10px 4px", 79 75 paddingInline: "12px",
+4 -1
packages/app/src/pages/sphere-settings.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import { sphereState, sphereHandle, refreshSphere } from "@exosphere/client/sphere"; 3 - import { canDo } from "@exosphere/client/permissions"; 3 + import { canDo, canAccessSettings, canManageModules } from "@exosphere/client/permissions"; 4 4 import { useLocation } from "@exosphere/client/router"; 5 5 import { useQuery } from "@exosphere/client/hooks"; 6 6 import * as ui from "@exosphere/client/ui.css"; 7 7 import * as ss from "./sphere-settings.css.ts"; 8 8 import { SettingsLayout } from "./settings-layout.tsx"; 9 + import { NotFoundPage } from "./not-found.tsx"; 9 10 import { 10 11 getSphereModules, 11 12 enableModule as apiEnableModule, ··· 161 162 const handle = sphereHandle.value; 162 163 163 164 if (!data || !handle) return null; 165 + if (!canAccessSettings()) return <NotFoundPage />; 164 166 165 167 const section = resolveSection(path ?? ""); 168 + if (section === "modules" && !canManageModules()) return <NotFoundPage />; 166 169 167 170 return ( 168 171 <SettingsLayout active={section}>
+25
packages/client/src/permissions.ts
··· 13 13 export function useCanDo(module: string, action: string) { 14 14 return computed(() => canDo(module, action)); 15 15 } 16 + 17 + /** True if the user can manage the sphere's enabled modules. */ 18 + export function canManageModules(): boolean { 19 + return canDo("sphere", "enableModule") || canDo("sphere", "disableModule"); 20 + } 21 + 22 + /** True if the user can manage members (invite, revoke, update role). */ 23 + export function canManageMembers(): boolean { 24 + return ( 25 + canDo("sphere", "inviteMember") || 26 + canDo("sphere", "revokeMember") || 27 + canDo("sphere", "updateMemberRole") 28 + ); 29 + } 30 + 31 + /** True if the user has at least one settings-related permission. 32 + * Used to gate the Settings entry point and the Settings page itself. */ 33 + export function canAccessSettings(): boolean { 34 + return ( 35 + canManageModules() || 36 + canManageMembers() || 37 + canDo("sphere", "manageLabels") || 38 + canDo("sphere", "updatePermissions") 39 + ); 40 + }