From b258408121613bc9d6f29587a23011c4872cc4be Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 12 May 2026 11:12:49 -0500 Subject: [PATCH] fix: use correct permission IDs in dashboard fixes #24 Signed-off-by: Trezy --- web/src/app/dashboard/settings/users/page.tsx | 172 +++++++++++++----- web/src/components/ui/sonner.tsx | 4 + 2 files changed, 132 insertions(+), 44 deletions(-) diff --git a/web/src/app/dashboard/settings/users/page.tsx b/web/src/app/dashboard/settings/users/page.tsx index e41f8cd..6bb9f43 100644 --- a/web/src/app/dashboard/settings/users/page.tsx +++ b/web/src/app/dashboard/settings/users/page.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronRight, Search, Shield, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { useAuth } from "@/lib/auth-context"; import { @@ -76,6 +77,8 @@ export default function UsersPage() { const [handles, setHandles] = useState>({}); const [error, setError] = useState(null); const [selectedUserId, setSelectedUserId] = useState(null); + const [pendingPermissions, setPendingPermissions] = useState([]); + const [saving, setSaving] = useState(false); const [permSearch, setPermSearch] = useState(""); const [permissionEntries, setPermissionEntries] = useState([]); const [profiles, setProfiles] = useState>({}); @@ -141,6 +144,13 @@ export default function UsersPage() { } }, [users, handles]); + // Initialize pending permissions when a user is selected + useEffect(() => { + if (!selectedUserId) return; + const user = users.find((u) => u.id === selectedUserId); + if (user) setPendingPermissions([...user.permissions]); + }, [selectedUserId, users]); + // Fetch Bluesky profile when a user is selected useEffect(() => { if (!selectedUserId) return; @@ -171,53 +181,59 @@ export default function UsersPage() { } } - async function handleTogglePermission( - user: UserSummary, + function handleTogglePermission( + _user: UserSummary, permission: string, enabled: boolean ) { - const grant: string[] = []; - const revoke: string[] = []; - - const [ns, action] = permission.split(":"); - - if (enabled) { - grant.push(permission); - // Adding a write permission also enables its read counterpart - if (action === "create" || action === "update" || action === "delete") { - const readPerm = `${ns}:read`; - if (!user.permissions.includes(readPerm)) { - grant.push(readPerm); - } - } - // Adding records:delete-collection also enables records:delete - if (permission === "records:delete-collection" && !user.permissions.includes("records:delete")) { - grant.push("records:delete"); - } - } else { - revoke.push(permission); - // Removing read also removes all write permissions in the same namespace - if (action === "read") { - for (const p of user.permissions) { - if (p.startsWith(`${ns}:`) && p !== permission) { - revoke.push(p); + setPendingPermissions((prev) => { + const perms = new Set(prev); + const [ns, action] = permission.split(":"); + + const nsReadPerm = allPermissionKeys.find( + (k) => k.startsWith(`${ns}:`) && (k.endsWith(":read") || k.endsWith(":view")) + ); + const isReadAction = action === "read" || action === "view"; + + if (enabled) { + perms.add(permission); + if (!isReadAction && nsReadPerm) perms.add(nsReadPerm); + if (permission === "records:delete-collection") perms.add("records:delete"); + } else { + perms.delete(permission); + if (isReadAction) { + for (const p of prev) { + if (p.startsWith(`${ns}:`) && p !== permission) perms.delete(p); } } + if (permission === "records:delete") perms.delete("records:delete-collection"); } - // Removing records:delete also removes records:delete-collection - if (permission === "records:delete" && user.permissions.includes("records:delete-collection")) { - revoke.push("records:delete-collection"); - } - } + return [...perms]; + }); + } + + async function handleSavePermissions(userId: string, originalPermissions: string[]) { + const originalSet = new Set(originalPermissions); + const pendingSet = new Set(pendingPermissions); + + const grant = pendingPermissions.filter((p) => !originalSet.has(p)); + const revoke = originalPermissions.filter((p) => !pendingSet.has(p)); + + if (grant.length === 0 && revoke.length === 0) return; + + setSaving(true); try { const body: { grant?: string[]; revoke?: string[] } = {}; if (grant.length > 0) body.grant = grant; if (revoke.length > 0) body.revoke = revoke; - await updateUserPermissions(user.id, body); + await updateUserPermissions(userId, body); + toast.success("Permissions updated"); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); } } @@ -307,8 +323,27 @@ export default function UsersPage() { {(() => { const selectedUser = users.find((u) => u.id === selectedUserId); return ( - { if (!open) { setSelectedUserId(null); setPermSearch(""); } }}> - + { + if (!open) { + const user = users.find((u) => u.id === selectedUserId); + if (user) { + const origSet = new Set(user.permissions); + const pendSet = new Set(pendingPermissions); + const unsaved = pendingPermissions.some((p) => !origSet.has(p)) || user.permissions.some((p) => !pendSet.has(p)); + if (unsaved) { + toast.warning("You have unsaved changes. Save or cancel before closing."); + return; + } + } + setSelectedUserId(null); + setPermSearch(""); + } + }}> + { + if (e.target instanceof HTMLElement && e.target.closest("[data-sonner-toaster]")) { + e.preventDefault(); + } + }}> {selectedUser && ( <> @@ -349,6 +384,14 @@ export default function UsersPage() { + {(() => { + const originalSet = new Set(selectedUser.permissions); + const pendingSet = new Set(pendingPermissions); + const added = pendingPermissions.filter((p) => !originalSet.has(p)).length; + const removed = selectedUser.permissions.filter((p) => !pendingSet.has(p)).length; + const hasChanges = added > 0 || removed > 0; + return ( + <>
Role @@ -363,7 +406,14 @@ export default function UsersPage() {

{selectedUser.is_super ? `${allPermissionKeys.length}/${allPermissionKeys.length}` - : `${selectedUser.permissions.filter((p) => allPermissionKeys.includes(p)).length}/${allPermissionKeys.length}`} + : `${pendingPermissions.filter((p) => allPermissionKeys.includes(p)).length}/${allPermissionKeys.length}`} + {hasChanges && ( + + {added > 0 && +{added}} + {added > 0 && removed > 0 && " "} + {removed > 0 && -{removed}} + + )}

@@ -399,12 +449,23 @@ export default function UsersPage() { currentUserPermissions={currentUser?.permissions ?? []} isCurrentUserSuper={isCurrentUserSuper} filteredCategories={filteredCategories} + pendingPermissions={pendingPermissions} + originalPermissions={selectedUser.permissions} onToggle={handleTogglePermission} />
-
+
+ {isCurrentUserSuper && ( handleTransferSuper(selectedUser.id)} /> )} +
+
+
+ + ); + })()} )} @@ -440,6 +512,8 @@ function PermissionsPanel({ currentUserPermissions, isCurrentUserSuper, filteredCategories, + pendingPermissions, + originalPermissions, onToggle, }: { user: UserSummary; @@ -447,9 +521,12 @@ function PermissionsPanel({ currentUserPermissions: string[]; isCurrentUserSuper: boolean; filteredCategories: Record; + pendingPermissions: string[]; + originalPermissions: string[]; onToggle: (user: UserSummary, permission: string, enabled: boolean) => void; }) { const canUpdate = isCurrentUserSuper || currentUserPermissions.includes("users:update"); + const originalSet = new Set(originalPermissions); return (
@@ -460,7 +537,10 @@ function PermissionsPanel({

{permissions.map((perm) => { - const enabled = user.is_super || user.permissions.includes(perm.key); + const enabled = user.is_super || pendingPermissions.includes(perm.key); + const wasEnabled = user.is_super || originalSet.has(perm.key); + const isAdded = enabled && !wasEnabled; + const isRemoved = !enabled && wasEnabled; return (
- {perm.name} + + {perm.name} + {isAdded && } + {isRemoved && } + {perm.description}
diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index 9b20afe..7a41a46 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -17,6 +17,7 @@ const Toaster = ({ ...props }: ToasterProps) => { , info: , @@ -30,6 +31,9 @@ const Toaster = ({ ...props }: ToasterProps) => { "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", "--border-radius": "var(--radius)", + "--toast-close-button-start": "unset", + "--toast-close-button-end": "0", + "--toast-close-button-transform": "translate(35%, -35%)", } as React.CSSProperties } {...props} -- 2.51.2