feat: add role-based access control
This commit is contained in:
parent
c42b8e2904
commit
8ab9a9a497
21 changed files with 629 additions and 46 deletions
|
|
@ -15,6 +15,7 @@ import {
|
|||
LayoutGrid,
|
||||
MenuIcon,
|
||||
Server,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -25,30 +26,42 @@ const dashboardLinks = [
|
|||
label: "Overview",
|
||||
description: "Health, alerts, and recent checks",
|
||||
icon: LayoutGrid,
|
||||
permission: "dashboard.read",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/servers",
|
||||
label: "Servers",
|
||||
description: "Inventory and host details",
|
||||
icon: Server,
|
||||
permission: "servers.read",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/services",
|
||||
label: "Services",
|
||||
description: "Processes and uptime targets",
|
||||
icon: Wrench,
|
||||
permission: "services.read",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/domains",
|
||||
label: "Domains",
|
||||
description: "DNS, SSL, and expiry status",
|
||||
icon: Globe,
|
||||
permission: "domains.read",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/notes",
|
||||
label: "Notes",
|
||||
description: "Runbooks and operational handover",
|
||||
icon: BookText,
|
||||
permission: "notes.read",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/users",
|
||||
label: "Users",
|
||||
description: "Roles and permission assignments",
|
||||
icon: Users,
|
||||
permission: "users.read",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
|
@ -60,14 +73,18 @@ function isActive(pathname: string, to: string): boolean {
|
|||
|
||||
function NavItems({
|
||||
pathname,
|
||||
permissions,
|
||||
onNavigate,
|
||||
}: {
|
||||
pathname: string;
|
||||
permissions: string[];
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const allowedPermissions = new Set(permissions);
|
||||
|
||||
return (
|
||||
<nav className="grid gap-1 p-2" aria-label="Dashboard sections">
|
||||
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
|
||||
{dashboardLinks.filter((link) => allowedPermissions.has(link.permission)).map(({ to, label, description, icon: Icon }) => {
|
||||
const active = isActive(pathname, to);
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -97,15 +114,18 @@ function NavItems({
|
|||
export function DashboardShell({
|
||||
userName,
|
||||
userEmail,
|
||||
permissions,
|
||||
}: {
|
||||
userName?: string | null;
|
||||
userEmail?: string | null;
|
||||
permissions: string[];
|
||||
}) {
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
});
|
||||
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const allowedPermissions = new Set(permissions);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
|
|
@ -139,6 +159,7 @@ export function DashboardShell({
|
|||
</SheetHeader>
|
||||
<NavItems
|
||||
pathname={pathname}
|
||||
permissions={permissions}
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
</SheetContent>
|
||||
|
|
@ -171,14 +192,18 @@ export function DashboardShell({
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to="/dashboard/servers/new">
|
||||
<Button size="sm">Add server</Button>
|
||||
</Link>
|
||||
<Link to="/dashboard/notes/new">
|
||||
<Button size="sm" variant="outline">
|
||||
New note
|
||||
</Button>
|
||||
</Link>
|
||||
{allowedPermissions.has("servers.create") ? (
|
||||
<Link to="/dashboard/servers/new">
|
||||
<Button size="sm">Add server</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{allowedPermissions.has("notes.create") ? (
|
||||
<Link to="/dashboard/notes/new">
|
||||
<Button size="sm" variant="outline">
|
||||
New note
|
||||
</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -192,7 +217,7 @@ export function DashboardShell({
|
|||
Navigation
|
||||
</div>
|
||||
</div>
|
||||
<NavItems pathname={pathname} />
|
||||
<NavItems pathname={pathname} permissions={permissions} />
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
|||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as DashboardIndexRouteImport } from './routes/dashboard.index'
|
||||
import { Route as DashboardUsersRouteImport } from './routes/dashboard.users'
|
||||
import { Route as DashboardServicesRouteImport } from './routes/dashboard.services'
|
||||
import { Route as DashboardServersRouteImport } from './routes/dashboard.servers'
|
||||
import { Route as DashboardNotesRouteImport } from './routes/dashboard.notes'
|
||||
|
|
@ -46,6 +47,11 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
const DashboardUsersRoute = DashboardUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
const DashboardServicesRoute = DashboardServicesRouteImport.update({
|
||||
id: '/services',
|
||||
path: '/services',
|
||||
|
|
@ -118,6 +124,7 @@ export interface FileRoutesByFullPath {
|
|||
'/dashboard/notes': typeof DashboardNotesRoute
|
||||
'/dashboard/servers': typeof DashboardServersRoute
|
||||
'/dashboard/services': typeof DashboardServicesRoute
|
||||
'/dashboard/users': typeof DashboardUsersRoute
|
||||
'/dashboard/': typeof DashboardIndexRoute
|
||||
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||
|
|
@ -135,6 +142,7 @@ export interface FileRoutesByTo {
|
|||
'/dashboard/notes': typeof DashboardNotesRoute
|
||||
'/dashboard/servers': typeof DashboardServersRoute
|
||||
'/dashboard/services': typeof DashboardServicesRoute
|
||||
'/dashboard/users': typeof DashboardUsersRoute
|
||||
'/dashboard': typeof DashboardIndexRoute
|
||||
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||
|
|
@ -154,6 +162,7 @@ export interface FileRoutesById {
|
|||
'/dashboard/notes': typeof DashboardNotesRoute
|
||||
'/dashboard/servers': typeof DashboardServersRoute
|
||||
'/dashboard/services': typeof DashboardServicesRoute
|
||||
'/dashboard/users': typeof DashboardUsersRoute
|
||||
'/dashboard/': typeof DashboardIndexRoute
|
||||
'/dashboard/domains_/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||
'/dashboard/domains_/new': typeof DashboardDomainsNewRoute
|
||||
|
|
@ -174,6 +183,7 @@ export interface FileRouteTypes {
|
|||
| '/dashboard/notes'
|
||||
| '/dashboard/servers'
|
||||
| '/dashboard/services'
|
||||
| '/dashboard/users'
|
||||
| '/dashboard/'
|
||||
| '/dashboard/domains/$domainId'
|
||||
| '/dashboard/domains/new'
|
||||
|
|
@ -191,6 +201,7 @@ export interface FileRouteTypes {
|
|||
| '/dashboard/notes'
|
||||
| '/dashboard/servers'
|
||||
| '/dashboard/services'
|
||||
| '/dashboard/users'
|
||||
| '/dashboard'
|
||||
| '/dashboard/domains/$domainId'
|
||||
| '/dashboard/domains/new'
|
||||
|
|
@ -209,6 +220,7 @@ export interface FileRouteTypes {
|
|||
| '/dashboard/notes'
|
||||
| '/dashboard/servers'
|
||||
| '/dashboard/services'
|
||||
| '/dashboard/users'
|
||||
| '/dashboard/'
|
||||
| '/dashboard/domains_/$domainId'
|
||||
| '/dashboard/domains_/new'
|
||||
|
|
@ -256,6 +268,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof DashboardIndexRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
'/dashboard/users': {
|
||||
id: '/dashboard/users'
|
||||
path: '/users'
|
||||
fullPath: '/dashboard/users'
|
||||
preLoaderRoute: typeof DashboardUsersRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
'/dashboard/services': {
|
||||
id: '/dashboard/services'
|
||||
path: '/services'
|
||||
|
|
@ -348,6 +367,7 @@ interface DashboardRouteChildren {
|
|||
DashboardNotesRoute: typeof DashboardNotesRoute
|
||||
DashboardServersRoute: typeof DashboardServersRoute
|
||||
DashboardServicesRoute: typeof DashboardServicesRoute
|
||||
DashboardUsersRoute: typeof DashboardUsersRoute
|
||||
DashboardIndexRoute: typeof DashboardIndexRoute
|
||||
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
|
||||
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
|
||||
|
|
@ -364,6 +384,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
|
|||
DashboardNotesRoute: DashboardNotesRoute,
|
||||
DashboardServersRoute: DashboardServersRoute,
|
||||
DashboardServicesRoute: DashboardServicesRoute,
|
||||
DashboardUsersRoute: DashboardUsersRoute,
|
||||
DashboardIndexRoute: DashboardIndexRoute,
|
||||
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
|
||||
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { DashboardShell } from "@/components/dashboard-shell";
|
||||
import { requireSession } from "@/lib/protected-route";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: RouteComponent,
|
||||
|
|
@ -10,8 +12,13 @@ export const Route = createFileRoute("/dashboard")({
|
|||
|
||||
function RouteComponent() {
|
||||
const { session } = Route.useRouteContext();
|
||||
const me = useQuery(trpc.users.me.queryOptions());
|
||||
|
||||
return (
|
||||
<DashboardShell userName={session.data?.user.name} userEmail={session.data?.user.email} />
|
||||
<DashboardShell
|
||||
userName={session.data?.user.name}
|
||||
userEmail={session.data?.user.email}
|
||||
permissions={me.data?.permissions ?? []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
149
apps/web/src/routes/dashboard.users.tsx
Normal file
149
apps/web/src/routes/dashboard.users.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Button } from "@minmon/ui/components/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@minmon/ui/components/card";
|
||||
import { Checkbox } from "@minmon/ui/components/checkbox";
|
||||
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/users")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
type RoleOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
function RoleBadges({ roles }: { roles: { id: string; name: string }[] }) {
|
||||
if (!roles.length) {
|
||||
return <span className="text-muted-foreground">No roles</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{roles.map((role) => (
|
||||
<StatusBadge key={role.id} variant={role.name === "Admin" ? "info" : role.name === "Editor" ? "warning" : "neutral"}>
|
||||
{role.name}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
|
||||
const users = useQuery(trpc.users.list.queryOptions());
|
||||
const updateRoles = useMutation({
|
||||
mutationFn: async ({ userId, roleIds }: { userId: string; roleIds: string[] }) =>
|
||||
trpcClient.users.updateRoles.mutate({ userId, roleIds }),
|
||||
onSuccess: async () => {
|
||||
toast.success("User roles updated");
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
if (users.isLoading) {
|
||||
return <QueryStateCard title="Loading users" description="Fetching users and their current role assignments." />;
|
||||
}
|
||||
|
||||
if (users.isError || !users.data) {
|
||||
return <QueryStateCard title="Users unavailable" description="User role assignments could not be loaded." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourceListCard
|
||||
title="User role assignments"
|
||||
description="Grant one or more roles to each user. Changes are saved immediately."
|
||||
columns={[
|
||||
{
|
||||
key: "user",
|
||||
header: "User",
|
||||
render: (item: (typeof users.data)[number]) => (
|
||||
<div className="grid gap-1">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.email}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "roles",
|
||||
header: "Current roles",
|
||||
render: (item: (typeof users.data)[number]) => <RoleBadges roles={item.roles} />,
|
||||
},
|
||||
{
|
||||
key: "assign",
|
||||
header: "Assign roles",
|
||||
render: (item: (typeof users.data)[number]) => {
|
||||
const assignedRoleIds = new Set(item.roles.map((role) => role.id));
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{roles.map((role) => {
|
||||
const checked = assignedRoleIds.has(role.id);
|
||||
const nextRoleIds = checked
|
||||
? item.roles.map((assignedRole) => assignedRole.id).filter((roleId) => roleId !== role.id)
|
||||
: [...item.roles.map((assignedRole) => assignedRole.id), role.id];
|
||||
|
||||
return (
|
||||
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={updateRoles.isPending || nextRoleIds.length === 0}
|
||||
onCheckedChange={() => updateRoles.mutate({ userId: item.id, roleIds: nextRoleIds })}
|
||||
/>
|
||||
<span>{role.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
items={users.data}
|
||||
emptyTitle="No users found"
|
||||
emptyDescription="Users appear here after they sign up or are seeded."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const roles = useQuery(trpc.users.roles.queryOptions());
|
||||
|
||||
if (roles.isLoading) {
|
||||
return <QueryStateCard title="Loading roles" description="Fetching available Admin, Editor, and Viewer roles." />;
|
||||
}
|
||||
|
||||
if (roles.isError || !roles.data) {
|
||||
return <QueryStateCard title="Roles unavailable" description="Role options could not be loaded right now." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title="Users and roles" description="Assign Admin, Editor, or Viewer access to application users.">
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Role guide</CardTitle>
|
||||
<CardDescription>Default RBAC roles seeded by the application.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
{roles.data.map((role) => (
|
||||
<div key={role.id} className="grid gap-1 border p-3">
|
||||
<StatusBadge variant={role.name === "Admin" ? "info" : role.name === "Editor" ? "warning" : "neutral"}>
|
||||
{role.name}
|
||||
</StatusBadge>
|
||||
<p className="text-sm text-muted-foreground">{role.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<RoleAssignmentCard roles={roles.data} />
|
||||
</div>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue