diff --git a/web/src/app/dashboard/settings/users/page.tsx b/web/src/app/dashboard/settings/users/page.tsx --- a/web/src/app/dashboard/settings/users/page.tsx +++ b/web/src/app/dashboard/settings/users/page.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; -import { ChevronDown, ChevronRight, Shield, Trash2 } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronRight, Search, Shield, Trash2 } from "lucide-react"; import { useAuth } from "@/lib/auth-context"; import { @@ -10,13 +10,16 @@ addUser, deleteUser, updateUserPermissions, transferSuper, + getPermissions, } from "@/lib/api"; +import type { PermissionEntry, PermissionTemplate } from "@/lib/api"; import type { UserSummary } from "@/types/users"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Select, @@ -43,45 +46,61 @@ ResponsiveDialogHeader, ResponsiveDialogTitle, ResponsiveDialogTrigger, } from "@/components/ui/responsive-dialog"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@/components/ui/sheet"; -const PERMISSION_CATEGORIES: Record = { - Lexicons: ["lexicons:create", "lexicons:read", "lexicons:delete"], - Records: ["records:read", "records:delete", "records:delete-collection"], - "Script Variables": [ - "script-variables:create", - "script-variables:read", - "script-variables:delete", - ], - Users: ["users:create", "users:read", "users:update", "users:delete"], - "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], - Backfill: ["backfill:create", "backfill:read"], - "API Clients": ["api-clients:view", "api-clients:create", "api-clients:edit", "api-clients:delete"], - Plugins: ["plugins:read", "plugins:create", "plugins:delete"], - System: ["stats:read", "events:read"], +type BskyProfile = { + avatar?: string; + displayName?: string; + description?: string; }; -const ALL_PERMISSIONS = Object.values(PERMISSION_CATEGORIES).flat(); - -const TEMPLATES = [ - { value: "viewer", label: "Viewer" }, - { value: "operator", label: "Operator" }, - { value: "manager", label: "Manager" }, - { value: "full_access", label: "Full Access" }, -] as const; - -const TEMPLATE_PERMISSIONS: Record = { - viewer: ["lexicons:read", "records:read", "script-variables:read", "users:read", "api-keys:read", "backfill:read", "stats:read", "events:read"], - operator: ["lexicons:read", "records:read", "records:delete", "script-variables:read", "script-variables:create", "users:read", "api-keys:read", "backfill:read", "backfill:create", "stats:read", "events:read"], - manager: ["lexicons:create", "lexicons:read", "lexicons:delete", "records:read", "records:delete", "records:delete-collection", "script-variables:create", "script-variables:read", "script-variables:delete", "users:read", "api-keys:read", "backfill:create", "backfill:read", "stats:read", "events:read", "plugins:read", "plugins:create", "plugins:delete"], - full_access: ALL_PERMISSIONS, -}; +function buildCategories(permissions: PermissionEntry[]): Record { + const cats: Record = {}; + for (const p of permissions) { + if (!cats[p.category]) cats[p.category] = []; + cats[p.category].push(p); + } + return cats; +} export default function UsersPage() { const { did: currentDid } = useAuth(); const [users, setUsers] = useState([]); const [handles, setHandles] = useState>({}); const [error, setError] = useState(null); - const [expandedUserId, setExpandedUserId] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(null); + const [permSearch, setPermSearch] = useState(""); + const [permissionEntries, setPermissionEntries] = useState([]); + const [profiles, setProfiles] = useState>({}); + const [templates, setTemplates] = useState([]); + + const permissionCategories = React.useMemo(() => buildCategories(permissionEntries), [permissionEntries]); + const filteredCategories = useMemo(() => { + if (!permSearch.trim()) return permissionCategories; + const terms = permSearch.toLowerCase().split(/\s+/); + const result: Record = {}; + for (const [category, permissions] of Object.entries(permissionCategories)) { + const matched = permissions.filter((p) => { + const haystack = `${p.name} ${p.description} ${p.category} ${p.key}`.toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }); + if (matched.length > 0) result[category] = matched; + } + return result; + }, [permissionCategories, permSearch]); + const allPermissionKeys = React.useMemo(() => permissionEntries.map((p) => p.key), [permissionEntries]); + const templatePermissions = React.useMemo(() => { + const map: Record = {}; + for (const t of templates) map[t.key] = t.permissions; + return map; + }, [templates]); const currentUser = users.find((u) => u.did === currentDid); const isCurrentUserSuper = currentUser?.is_super ?? false; @@ -94,6 +113,12 @@ }, []); useEffect(() => { load(); + getPermissions() + .then((catalog) => { + setPermissionEntries(catalog.permissions); + setTemplates(catalog.templates); + }) + .catch((e) => setError(e instanceof Error ? e.message : String(e))); }, [load]); // Resolve DIDs to handles via PLC directory @@ -115,6 +140,27 @@ }) .catch(() => {}); } }, [users, handles]); + + // Fetch Bluesky profile when a user is selected + useEffect(() => { + if (!selectedUserId) return; + const user = users.find((u) => u.id === selectedUserId); + if (!user || user.did in profiles) return; + fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user.did)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setProfiles((prev) => ({ + ...prev, + [user.did]: { + avatar: data.avatar, + displayName: data.displayName, + description: data.description, + }, + })); + }) + .catch(() => {}); + }, [selectedUserId, users, profiles]); async function handleDelete(id: string) { try { @@ -193,7 +239,7 @@

Users

{(isCurrentUserSuper || currentUser?.permissions.includes("users:create")) && ( - + )}
@@ -201,19 +247,18 @@
- User Permissions Created Last Used - + {users.length === 0 && ( No users yet. @@ -221,125 +266,169 @@ )} {users.map((user) => ( - - - -
+
+ + {(() => { + const selectedUser = users.find((u) => u.id === selectedUserId); + return ( + { if (!open) { setSelectedUserId(null); setPermSearch(""); } }}> + + {selectedUser && ( + <> + + User + + + + + {profiles[selectedUser.did]?.avatar && ( + + )} +
+

+ {profiles[selectedUser.did]?.displayName || handles[selectedUser.did] ? ( + <> + {profiles[selectedUser.did]?.displayName && ( + {profiles[selectedUser.did].displayName} + )} + {handles[selectedUser.did] && ( + + @{handles[selectedUser.did]} + + )} + ) : ( - - )} - - - - setExpandedUserId( - expandedUserId === user.id ? null : user.id - ) - } - > -

-
- {handles[user.did] && ( - @{handles[user.did]} + {selectedUser.did} )} - {user.did} -
- {user.is_super && ( - - Owner - +

+

{selectedUser.did}

+ {profiles[selectedUser.did]?.description && ( +

{profiles[selectedUser.did].description}

)}
- - - setExpandedUserId( - expandedUserId === user.id ? null : user.id - ) - } - > - {user.is_super - ? `${ALL_PERMISSIONS.length}/${ALL_PERMISSIONS.length}` - : `${user.permissions.length}/${ALL_PERMISSIONS.length}`} - - - setExpandedUserId( - expandedUserId === user.id ? null : user.id - ) - } - > - {new Date(user.created_at).toLocaleString()} - - - setExpandedUserId( - expandedUserId === user.id ? null : user.id - ) - } - > - {user.last_used_at - ? new Date(user.last_used_at).toLocaleString() - : "Never"} - - -
- {isCurrentUserSuper && ( - handleTransferSuper(user.id)} - /> - )} - -
-
- - {expandedUserId === user.id && ( - - - + + +
+
+ Role +

+ {selectedUser.is_super ? ( + Owner + ) : "Member"} +

+
+
+ Permissions +

+ {selectedUser.is_super + ? `${allPermissionKeys.length}/${allPermissionKeys.length}` + : `${selectedUser.permissions.filter((p) => allPermissionKeys.includes(p)).length}/${allPermissionKeys.length}`} +

+
+
+ Created +

{new Date(selectedUser.created_at).toLocaleString()}

+
+
+ Last Active +

+ {selectedUser.last_used_at + ? new Date(selectedUser.last_used_at).toLocaleString() + : "Never"} +

+
+
+ +
+ +
+ + setPermSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ +
+ + +
+ {isCurrentUserSuper && ( + handleTransferSuper(selectedUser.id)} /> - - - )} - - ))} - - -
+ )} + +
+ + + )} +
+
+ ); + })()} ); @@ -350,47 +439,50 @@ user, isSelf, currentUserPermissions, isCurrentUserSuper, + filteredCategories, onToggle, }: { user: UserSummary; isSelf: boolean; currentUserPermissions: string[]; isCurrentUserSuper: boolean; + filteredCategories: Record; onToggle: (user: UserSummary, permission: string, enabled: boolean) => void; }) { const canUpdate = isCurrentUserSuper || currentUserPermissions.includes("users:update"); return ( -
- {Object.entries(PERMISSION_CATEGORIES).map(([category, permissions]) => ( -
+
+ {Object.entries(filteredCategories).map(([category, permissions]) => ( +

{category}

-
+
{permissions.map((perm) => { - const enabled = user.is_super || user.permissions.includes(perm); + const enabled = user.is_super || user.permissions.includes(perm.key); return ( -
+
- onToggle(user, perm, checked) + onToggle(user, perm.key, checked) } - className="scale-75" + className="mt-0.5 scale-75" />
); @@ -422,14 +514,12 @@ return ( @@ -457,8 +547,12 @@ } function AddUserDialog({ onSuccess, + templates, + templatePermissions, }: { onSuccess: () => void; + templates: PermissionTemplate[]; + templatePermissions: Record; }) { const [did, setDid] = useState(""); const [template, setTemplate] = useState(""); @@ -511,8 +605,8 @@ - {TEMPLATES.map((t) => ( - + {templates.map((t) => ( + {t.label} ))} @@ -521,7 +615,7 @@
{template && (

- Grants {TEMPLATE_PERMISSIONS[template]?.length ?? 0} permissions. + Grants {templatePermissions[template]?.length ?? 0} permissions.

)}