diff --git a/SW.Bitween.Web/ClientApp/src/nav.ts b/SW.Bitween.Web/ClientApp/src/nav.ts index 6743d63f..3b36679c 100644 --- a/SW.Bitween.Web/ClientApp/src/nav.ts +++ b/SW.Bitween.Web/ClientApp/src/nav.ts @@ -14,6 +14,7 @@ import { RotateCcw, ScrollText, Settings, + ShieldCheck, SlidersHorizontal, Users, Webhook, @@ -96,7 +97,11 @@ export const NAV_GROUPS: NavGroup[] = [ { label: "Administration", items: [ - { label: "Team", path: "/team", icon: Users, permissions: ["users.view", "roles.view"] }, + // Who can sign in, then what signing in lets them do. Two entries rather than one + // "Team" with tabs inside it: they are gated on different permissions, so a session + // that holds only one of them used to land on a page whose other half was a dead tab. + { label: "Members", path: "/team/members", icon: Users, permissions: ["users.view"] }, + { label: "Roles", path: "/team/roles", icon: ShieldCheck, permissions: ["roles.view"] }, { label: "Settings", path: "/settings", icon: Settings, permissions: ["settings.view"] }, // Last in Administration: it reports on everything above it rather than configuring // anything, and it is the one page whose value is that nobody can quietly change it. diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx index d2930f99..c2ab9498 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx @@ -12,7 +12,7 @@ import { Badge, Button, FormError, LoadingBlock } from "../../components/ui/basi import { Checkbox, PasswordInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; import { formatDate, timeAgo, timeUntil } from "../../lib/dates"; -import { statusBadge } from "./MembersTab"; +import { statusBadge } from "./MembersPage"; import { keys } from "../../api/queryKeys"; function Section({ title, children }: { title: string; children: ReactNode }) { diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MembersPage.tsx similarity index 88% rename from SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx rename to SW.Bitween.Web/ClientApp/src/pages/team/MembersPage.tsx index 60e59871..9bcfb291 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MembersPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { Search, UserPlus, UsersRound } from "lucide-react"; import { api, type User, type UserStatus } from "../../api"; import { Can } from "../../auth/guards"; +import { PageHeader } from "../../components/layout/PageHeader"; import { Avatar } from "../../components/ui/Avatar"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { timeAgo } from "../../lib/dates"; @@ -25,7 +26,7 @@ export function statusBadge(status: UserStatus, lockedUntil?: string | null) { return Active; } -export function MembersTab() { +export function MembersPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const { id: openMemberId } = useParams(); @@ -67,6 +68,27 @@ export function MembersTab() { return (
+ +

+ Every member holds one or more roles, and every role is a list of{" "} + permissions — page by page, action by action. A member can do + something if any of their roles allows it; everything else is hidden from them. +

+

+ To bring someone in: create or pick a role under Roles, then add + them here. You set their first password and pass it on — Bitween doesn't send mail. +

+ + ), + }} + /> +
diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/RolesPage.tsx similarity index 85% rename from SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx rename to SW.Bitween.Web/ClientApp/src/pages/team/RolesPage.tsx index 447b2262..63f33b2b 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/RolesPage.tsx @@ -4,29 +4,40 @@ import { ChevronRight, Plus, ShieldCheck } from "lucide-react"; import { api } from "../../api"; import { allKeysIn, usePermissionCatalog } from "../../api/permissions"; import { Can } from "../../auth/guards"; +import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { keys } from "../../api/queryKeys"; -export function RolesTab() { +export function RolesPage() { const navigate = useNavigate(); const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const totalPermissions = allKeysIn(usePermissionCatalog().data ?? []).length; - if (roles.isPending) return ; - - return ( -
-
-

- A role is a reusable set of permissions. Open one to see — and shape — exactly what its - members can do. -

+ const header = ( + + } + /> + ); + + if (roles.isPending) + return ( +
+ {header} +
+ ); + + return ( +
+ {header} {(roles.data ?? []).length === 0 ? ( } title="No roles yet"> diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/TeamPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/TeamPage.tsx deleted file mode 100644 index 46a6b722..00000000 --- a/SW.Bitween.Web/ClientApp/src/pages/team/TeamPage.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Navigate, NavLink, Outlet } from "react-router"; -import { useSession } from "../../auth/SessionContext"; -import { AccessDenied } from "../../auth/guards"; -import { PageHeader } from "../../components/layout/PageHeader"; - -/** - * One place for everything people-related: who can sign in (Members) - * and what they're allowed to do (Roles). Tabs are URL paths so every - * view is linkable. - */ -export function TeamPage() { - const { can } = useSession(); - const canMembers = can("users.view"); - const canRoles = can("roles.view"); - - if (!canMembers && !canRoles) return ; - - const tabs = [ - ...(canMembers ? [{ to: "/team/members", label: "Members" }] : []), - ...(canRoles ? [{ to: "/team/roles", label: "Roles" }] : []), - ]; - - return ( -
- -

- Every member holds one or more roles, and every role is a list of{" "} - permissions — page by page, action by action. A member can do - something if any of their roles allows it; everything else is hidden from them. -

-

- To bring someone in: create or pick a role on the Roles tab, then add them from the - Members tab. You set their first password and pass it on — Bitween doesn't send mail. -

- - ), - }} - /> - -
- {tabs.map((tab) => ( - - `-mb-px border-b-2 px-3.5 py-2 text-sm font-medium transition-colors ${ - isActive - ? "border-crimson-600 text-crimson-700" - : "border-transparent text-ink-500 hover:border-ink-300 hover:text-ink-800" - }` - } - > - {tab.label} - - ))} -
- - -
- ); -} - -export function TeamIndexRedirect() { - const { can } = useSession(); - return ; -} diff --git a/SW.Bitween.Web/ClientApp/src/router.tsx b/SW.Bitween.Web/ClientApp/src/router.tsx index 6dd49bd5..6a487179 100644 --- a/SW.Bitween.Web/ClientApp/src/router.tsx +++ b/SW.Bitween.Web/ClientApp/src/router.tsx @@ -43,10 +43,9 @@ import { AggregationsPage } from "./pages/aggregations/AggregationsPage"; import { NewAggregationPage } from "./pages/aggregations/NewAggregationPage"; import { RetryPoliciesPage } from "./pages/retry-policies/RetryPoliciesPage"; import { RetryPolicyPage } from "./pages/retry-policies/RetryPolicyPage"; -import { MembersTab } from "./pages/team/MembersTab"; +import { MembersPage } from "./pages/team/MembersPage"; import { RoleEditor } from "./pages/team/RoleEditor"; -import { RolesTab } from "./pages/team/RolesTab"; -import { TeamIndexRedirect, TeamPage } from "./pages/team/TeamPage"; +import { RolesPage } from "./pages/team/RolesPage"; import { WorkGroupPage } from "./pages/work-groups/WorkGroupPage"; import { WorkGroupsPage } from "./pages/work-groups/WorkGroupsPage"; @@ -57,6 +56,12 @@ function HomeRedirect() { return ; } +/** An old "/team" link lands on whichever of the two pages this session can open. */ +function TeamRedirect() { + const { can } = useSession(); + return ; +} + const placeholderRoutes = NAV_GROUPS.flatMap((group) => group.items) .filter((item) => item.planned) .map((item) => ({ @@ -117,36 +122,32 @@ export const router = createBrowserRouter([ ), }, + // Members and Roles are two sidebar entries, not tabs inside a "Team" page, so + // "/team" itself is no longer a page — only a bookmark people may still hold. + { path: "team", element: }, + { + path: "team/members", + element: ( + + + + ), + }, + { + path: "team/members/:id", + element: ( + + + + ), + }, { - path: "team", - element: , - children: [ - { index: true, element: }, - { - path: "members", - element: ( - - - - ), - }, - { - path: "members/:id", - element: ( - - - - ), - }, - { - path: "roles", - element: ( - - - - ), - }, - ], + path: "team/roles", + element: ( + + + + ), }, { path: "team/roles/new",