From a42bba13cc2084c669c471880d091c50430f97a3 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Wed, 13 Aug 2025 12:37:07 -0500 Subject: [PATCH 01/15] extend getRoles to include users --- app/lib/services/roleService.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/lib/services/roleService.ts b/app/lib/services/roleService.ts index aa44b32e..0e63a329 100644 --- a/app/lib/services/roleService.ts +++ b/app/lib/services/roleService.ts @@ -11,11 +11,25 @@ export async function getRole(id: string): Promise { } export async function getRoles(): Promise { - return prisma.role.findMany({ + const roles = await prisma.role.findMany({ include: { permissions: true, + users: { + include: { + user: true, + }, + }, }, }); + + return roles.map((role) => ({ + ...role, + users: role.users.map((user) => ({ + id: user.user.id, + name: user.user.name, + email: user.user.email, + })), + })); } export async function createRole( From 73be881183179621fe4e066112cab53394677315 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Wed, 13 Aug 2025 13:12:47 -0500 Subject: [PATCH 02/15] add roles tab to control panel --- .../@settings/core/ControlPanel.tsx | 3 + app/components/@settings/core/constants.ts | 6 +- app/components/@settings/core/types.ts | 3 +- .../@settings/tabs/roles/RolesTab.tsx | 130 ++++++++++++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 app/components/@settings/tabs/roles/RolesTab.tsx diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 5043e862..261d3b3a 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -22,6 +22,7 @@ import { useUserStore } from '~/lib/stores/user'; import { DeprecatedRole } from '@prisma/client'; import OrganizationTab from '~/components/@settings/tabs/organization/OrganizationTab'; import MembersTab from '~/components/@settings/tabs/members/MembersTab'; +import RolesTab from '~/components/@settings/tabs/roles/RolesTab'; const LAST_ACCESSED_TAB_KEY = 'control-panel-last-tab'; @@ -166,6 +167,8 @@ export const ControlPanel = () => { return ; case 'members': return ; + case 'roles': + return ; default: return null; } diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.ts index 850fa2c4..8cdb7ce3 100644 --- a/app/components/@settings/core/constants.ts +++ b/app/components/@settings/core/constants.ts @@ -1,5 +1,5 @@ import type { TabType, TabVisibilityConfig } from './types'; -import { Building, Database, GitBranch, type LucideIcon, Rocket, Users } from 'lucide-react'; +import { Building, Database, GitBranch, type LucideIcon, Rocket, Users, ShieldUser } from 'lucide-react'; export const TAB_ICONS: Record = { data: Database, @@ -7,6 +7,7 @@ export const TAB_ICONS: Record = { 'deployed-apps': Rocket, organization: Building, members: Users, + roles: ShieldUser, }; export const TAB_LABELS: Record = { @@ -15,6 +16,7 @@ export const TAB_LABELS: Record = { 'deployed-apps': 'Deployed Apps', organization: 'Organization', members: 'Members', + roles: 'Roles', }; export const TAB_DESCRIPTIONS: Record = { @@ -23,6 +25,7 @@ export const TAB_DESCRIPTIONS: Record = { 'deployed-apps': 'View and manage your deployed applications', organization: 'Manage your organization', members: 'Manage your organization members', + roles: 'Manage roles and permissions for organization members', }; export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [ @@ -32,4 +35,5 @@ export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [ { id: 'organization', visible: true, window: 'admin', order: 0 }, { id: 'members', visible: true, window: 'admin', order: 1 }, + { id: 'roles', visible: true, window: 'admin', order: 2 }, ]; diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts index 1faf5b5c..43729c0a 100644 --- a/app/components/@settings/core/types.ts +++ b/app/components/@settings/core/types.ts @@ -1,4 +1,4 @@ -export type TabType = 'data' | 'github' | 'deployed-apps' | 'organization' | 'members'; +export type TabType = 'data' | 'github' | 'deployed-apps' | 'organization' | 'members' | 'roles'; export type WindowType = 'user' | 'admin'; @@ -30,4 +30,5 @@ export const TAB_LABELS: Record = { 'deployed-apps': 'Deployed Apps', organization: 'Organization', members: 'Members', + roles: 'Roles', }; diff --git a/app/components/@settings/tabs/roles/RolesTab.tsx b/app/components/@settings/tabs/roles/RolesTab.tsx new file mode 100644 index 00000000..91933d2e --- /dev/null +++ b/app/components/@settings/tabs/roles/RolesTab.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect, useMemo } from 'react'; +import { ChevronRight, Search } from 'lucide-react'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { toast } from 'sonner'; + +interface Role { + id: string; + name: string; + description: string; + organizationId: string; + permissions: Permission[]; + users: User[]; +} + +interface Permission { + id: string; + roleId: string; + action: string; + resource: string; + environmentId: string | null; + dataSourceId: string | null; + websiteId: string | null; +} + +interface User { + id: string; + name: string; + email: string; +} + +type LoaderData = { + roles: Role[]; +}; + +export default function RolesTab() { + const [searchQuery, setSearchQuery] = useState(''); + const [roles, setRoles] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fetchRoles = async () => { + try { + setIsLoading(true); + + const response = await fetch('/api/roles'); + const data: LoaderData = await response.json(); + + if (data.roles) { + setRoles(data.roles); + } + } catch (error) { + console.error('Error fetching roles:', error); + toast.error('Failed to fetch roles'); + } finally { + setIsLoading(false); + } + }; + + fetchRoles(); + }, []); + + const filteredRoles = useMemo( + () => roles.filter((role) => role.name.toLowerCase().includes(searchQuery.toLowerCase())), + [roles, searchQuery], + ); + + if (isLoading) { + return ( +
+
+

Roles

+ Loading... +
+
+
Loading roles...
+
+
+ ); + } + + return ( + +
+
+

Roles

+ {filteredRoles.length} +
+ +
+
+ +
+ setSearchQuery(e.target.value)} + className="w-[566px] h-9 pl-10 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-white placeholder-gray-400 focus:outline-none" + placeholder="Search..." + /> +
+ + + + + + + + + + + {filteredRoles.map((role) => ( + + + + + + ))} + +
RoleMembers
+
{role.name}
+ {role.description &&
{role.description}
} +
{role.users.length} +
+ +
+
+
+
+ ); +} From f07defabb66d2466e8d20c98b663ef6f3546b668 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Wed, 13 Aug 2025 16:38:24 -0500 Subject: [PATCH 03/15] add reusable control panel header with back functionality --- .../@settings/core/ControlPanel.tsx | 17 ++- .../@settings/core/ControlPanelHeader.tsx | 43 +++++++ .../@settings/tabs/roles/RoleDetails.tsx | 24 ++++ .../@settings/tabs/roles/RolesTab.tsx | 121 ++++++++---------- app/components/@settings/tabs/roles/types.ts | 24 ++++ app/lib/stores/settings.ts | 49 ++++++- 6 files changed, 195 insertions(+), 83 deletions(-) create mode 100644 app/components/@settings/core/ControlPanelHeader.tsx create mode 100644 app/components/@settings/tabs/roles/RoleDetails.tsx create mode 100644 app/components/@settings/tabs/roles/types.ts diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 261d3b3a..39384b48 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -5,13 +5,14 @@ import * as RadixDialog from '@radix-ui/react-dialog'; import { classNames } from '~/utils/classNames'; import { closeSettingsPanel, + resetControlPanelHeader, resetTabConfiguration, settingsPanelStore, tabConfigurationStore, } from '~/lib/stores/settings'; import type { TabType, TabVisibilityConfig } from './types'; import { DEFAULT_TAB_CONFIG, TAB_ICONS, TAB_LABELS } from './constants'; -import { CloseCircle } from 'iconsax-reactjs'; +import { ControlPanelHeader } from './ControlPanelHeader'; import { Settings } from 'lucide-react'; // Import all tab components @@ -151,6 +152,9 @@ export const ControlPanel = () => { const handleTabClick = (tabId: TabType) => { setActiveTab(tabId); + // Reset the header when switching tabs + resetControlPanelHeader(); + // Store the selected tab localStorage.setItem(LAST_ACCESSED_TAB_KEY, tabId); }; @@ -224,21 +228,16 @@ export const ControlPanel = () => { {/* Main Content */}
+ +
-
- -
{activeTab ? ( getTabComponent(activeTab) diff --git a/app/components/@settings/core/ControlPanelHeader.tsx b/app/components/@settings/core/ControlPanelHeader.tsx new file mode 100644 index 00000000..d0f3f012 --- /dev/null +++ b/app/components/@settings/core/ControlPanelHeader.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useStore } from '@nanostores/react'; +import { classNames } from '~/utils/classNames'; +import { ArrowLeft } from 'lucide-react'; +import { CloseCircle } from 'iconsax-reactjs'; +import { closeSettingsPanel, settingsPanelStore } from '~/lib/stores/settings'; + +export const ControlPanelHeader = () => { + const { header } = useStore(settingsPanelStore); + + return ( +
+
+
+ {header.onBack && ( + + )} + {header.title && ( +

{header.title}

+ )} +
+
+
+ +
+
+ ); +}; diff --git a/app/components/@settings/tabs/roles/RoleDetails.tsx b/app/components/@settings/tabs/roles/RoleDetails.tsx new file mode 100644 index 00000000..ffbe7318 --- /dev/null +++ b/app/components/@settings/tabs/roles/RoleDetails.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { resetControlPanelHeader, setControlPanelHeader } from '~/lib/stores/settings'; +import type { Role } from './types'; + +interface RoleDetailsProps { + role: Role; + onBack(): void; +} + +export default function RoleDetails({ role, onBack }: RoleDetailsProps) { + useEffect(() => { + setControlPanelHeader({ + title: `Edit "${role.name}"`, + onBack, + }); + + // Return a cleanup function to clear the header when the component unmounts + return () => { + resetControlPanelHeader(); + }; + }, [role.name]); + + return
Role Details
; +} diff --git a/app/components/@settings/tabs/roles/RolesTab.tsx b/app/components/@settings/tabs/roles/RolesTab.tsx index 91933d2e..d93d826e 100644 --- a/app/components/@settings/tabs/roles/RolesTab.tsx +++ b/app/components/@settings/tabs/roles/RolesTab.tsx @@ -1,32 +1,8 @@ import { useState, useEffect, useMemo } from 'react'; import { ChevronRight, Search } from 'lucide-react'; -import * as Tooltip from '@radix-ui/react-tooltip'; import { toast } from 'sonner'; - -interface Role { - id: string; - name: string; - description: string; - organizationId: string; - permissions: Permission[]; - users: User[]; -} - -interface Permission { - id: string; - roleId: string; - action: string; - resource: string; - environmentId: string | null; - dataSourceId: string | null; - websiteId: string | null; -} - -interface User { - id: string; - name: string; - email: string; -} +import RoleDetails from './RoleDetails'; +import type { Role } from './types'; type LoaderData = { roles: Role[]; @@ -36,6 +12,7 @@ export default function RolesTab() { const [searchQuery, setSearchQuery] = useState(''); const [roles, setRoles] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); useEffect(() => { const fetchRoles = async () => { @@ -78,53 +55,59 @@ export default function RolesTab() { ); } + if (selectedRole) { + return setSelectedRole(null)} />; + } + return ( - -
-
-

Roles

- {filteredRoles.length} -
+
+
+

Roles

+ {filteredRoles.length} +
-
-
- -
- setSearchQuery(e.target.value)} - className="w-[566px] h-9 pl-10 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-white placeholder-gray-400 focus:outline-none" - placeholder="Search..." - /> +
+
+
+ setSearchQuery(e.target.value)} + className="w-[566px] h-9 pl-10 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-white placeholder-gray-400 focus:outline-none" + placeholder="Search..." + /> +
- - - - - - +
RoleMembers
+ + + + + + + + + {filteredRoles.map((role) => ( + setSelectedRole(role)} + > + + + - - - {filteredRoles.map((role) => ( - - - - - - ))} - -
RoleMembers
+
{role.name}
+ {role.description &&
{role.description}
} +
{role.users.length} +
+ +
+
-
{role.name}
- {role.description &&
{role.description}
} -
{role.users.length} -
- -
-
-
- + ))} + + +
); } diff --git a/app/components/@settings/tabs/roles/types.ts b/app/components/@settings/tabs/roles/types.ts new file mode 100644 index 00000000..f00479d0 --- /dev/null +++ b/app/components/@settings/tabs/roles/types.ts @@ -0,0 +1,24 @@ +export interface Role { + id: string; + name: string; + description: string; + organizationId: string; + permissions: Permission[]; + users: User[]; +} + +export interface Permission { + id: string; + roleId: string; + action: string; + resource: string; + environmentId: string | null; + dataSourceId: string | null; + websiteId: string | null; +} + +export interface User { + id: string; + name: string; + email: string; +} diff --git a/app/lib/stores/settings.ts b/app/lib/stores/settings.ts index fd614e1a..0d407373 100644 --- a/app/lib/stores/settings.ts +++ b/app/lib/stores/settings.ts @@ -221,9 +221,7 @@ export const useSettingsStore = create((set) => ({ openSettings: () => { set({ isOpen: true, - selectedTab: 'user', - - // Always open to user tab + selectedTab: 'user', // Always open to user tab }); }, @@ -239,11 +237,20 @@ export const useSettingsStore = create((set) => ({ }, })); +export interface ControlPanelHeader { + title: string | null; + onBack: (() => void) | null; +} + export const settingsPanelStore = atom({ isOpen: false, selectedTab: 'data' as string | null, showAddForm: false, isHomepageSource: false, + header: { + title: null, + onBack: null, + } as ControlPanelHeader, }); export const openSettingsPanel = ( @@ -251,9 +258,41 @@ export const openSettingsPanel = ( showAddForm: boolean = false, isHomepageSource = false, ) => { - settingsPanelStore.set({ isOpen: true, selectedTab: tab, showAddForm, isHomepageSource }); + settingsPanelStore.set({ + isOpen: true, + selectedTab: tab, + showAddForm, + isHomepageSource, + header: { title: null, onBack: null }, + }); }; export const closeSettingsPanel = () => { - settingsPanelStore.set({ isOpen: false, selectedTab: null, showAddForm: false, isHomepageSource: false }); + settingsPanelStore.set({ + isOpen: false, + selectedTab: null, + showAddForm: false, + isHomepageSource: false, + header: { title: null, onBack: null }, + }); +}; + +export const setControlPanelHeader = (newHeader: Partial) => { + const currentStore = settingsPanelStore.get(); + settingsPanelStore.set({ + ...currentStore, + header: { ...currentStore.header, ...newHeader }, + }); +}; + +export const resetControlPanelHeader = () => { + const currentStore = settingsPanelStore.get(); + + // Only update if it's not already in its default state + if (currentStore.header.title !== null || currentStore.header.onBack !== null) { + settingsPanelStore.set({ + ...currentStore, + header: { title: null, onBack: null }, + }); + } }; From 4f5f59ccc0958a74c614bb9446cdeab637328a4e Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Wed, 13 Aug 2025 18:12:50 -0500 Subject: [PATCH 04/15] added support for update role name and description --- .../@settings/tabs/roles/RoleDetails.tsx | 87 ++++++++++++++++++- .../@settings/tabs/roles/RolesTab.tsx | 10 ++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/app/components/@settings/tabs/roles/RoleDetails.tsx b/app/components/@settings/tabs/roles/RoleDetails.tsx index ffbe7318..ebdfe9a1 100644 --- a/app/components/@settings/tabs/roles/RoleDetails.tsx +++ b/app/components/@settings/tabs/roles/RoleDetails.tsx @@ -1,24 +1,103 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { resetControlPanelHeader, setControlPanelHeader } from '~/lib/stores/settings'; +import { toast } from 'sonner'; import type { Role } from './types'; interface RoleDetailsProps { role: Role; onBack(): void; + onRoleUpdate: (updatedRole: Role) => void; } -export default function RoleDetails({ role, onBack }: RoleDetailsProps) { +export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsProps) { + const [roleName, setRoleName] = useState(role.name); + const [roleDescription, setRoleDescription] = useState(role.description); + const [isSaving, setIsSaving] = useState(false); + useEffect(() => { setControlPanelHeader({ title: `Edit "${role.name}"`, onBack, }); - // Return a cleanup function to clear the header when the component unmounts + // clear the header when the component unmounts return () => { resetControlPanelHeader(); }; }, [role.name]); - return
Role Details
; + const handleSave = async () => { + try { + setIsSaving(true); + + const response = await fetch(`/api/roles/${role.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: roleName, description: roleDescription || null }), + }); + + const data: { success: boolean; role: Role; error?: string } = await response.json(); + setIsSaving(false); + + if (data.success) { + onRoleUpdate({ ...role, name: data.role.name, description: data.role.description }); + toast.success('Role updated successfully'); + } else { + toast.error(data.error || 'Failed to update role'); + } + } catch (error) { + console.error('Error updating role:', error); + toast.error('Failed to update role'); + } finally { + setIsSaving(false); + } + }; + + const isSaveDisabled = + isSaving || roleName.trim() === '' || (role.name === roleName && role.description === roleDescription); + + return ( +
+
+ + setRoleName(e.target.value)} + className="w-full px-2 py-1 rounded-lg bg-gray-700 border border-gray-600 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="Enter role name" + /> +
+
+ + setRoleDescription(e.target.value)} + className="w-full px-2 py-1 rounded-lg bg-gray-700 border border-gray-600 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="Enter role description" + /> +
+ +
+
+ +
+
+
+ ); } diff --git a/app/components/@settings/tabs/roles/RolesTab.tsx b/app/components/@settings/tabs/roles/RolesTab.tsx index d93d826e..e4e8ea39 100644 --- a/app/components/@settings/tabs/roles/RolesTab.tsx +++ b/app/components/@settings/tabs/roles/RolesTab.tsx @@ -36,6 +36,14 @@ export default function RolesTab() { fetchRoles(); }, []); + const handleRoleUpdate = (updatedRole: Role) => { + setRoles((prevRoles) => prevRoles.map((role) => (role.id === updatedRole.id ? { ...role, ...updatedRole } : role))); + + if (selectedRole && selectedRole.id === updatedRole.id) { + setSelectedRole(updatedRole); + } + }; + const filteredRoles = useMemo( () => roles.filter((role) => role.name.toLowerCase().includes(searchQuery.toLowerCase())), [roles, searchQuery], @@ -56,7 +64,7 @@ export default function RolesTab() { } if (selectedRole) { - return setSelectedRole(null)} />; + return setSelectedRole(null)} onRoleUpdate={handleRoleUpdate} />; } return ( From 82f5ebb4f11f99f8ffaac9e0aa14bc46481c6637 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Thu, 14 Aug 2025 16:41:27 -0500 Subject: [PATCH 05/15] add radix toggle group --- package.json | 1 + pnpm-lock.yaml | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/package.json b/package.json index 6247b4d5..23ee5c7f 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.1.4", "@t3-oss/env-nextjs": "^0.13.8", "@tailwindcss/postcss": "^4.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ec65c73..dc743935 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.2 version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle-group': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.1.4 version: 1.2.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2937,6 +2940,9 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -3203,6 +3209,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-scroll-area@1.2.9': resolution: {integrity: sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==} peerDependencies: @@ -3277,6 +3296,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.2.7': resolution: {integrity: sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==} peerDependencies: @@ -14152,6 +14197,8 @@ snapshots: '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14427,6 +14474,23 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-scroll-area@1.2.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.1 @@ -14520,6 +14584,32 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-tooltip@1.2.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 From 46751538817c245e581cda9a871688f570f89555 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Thu, 14 Aug 2025 16:42:09 -0500 Subject: [PATCH 06/15] add initial role users to role details --- .../@settings/tabs/roles/RoleDetails.tsx | 53 +++++- .../@settings/tabs/roles/RoleMembers.tsx | 163 ++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 app/components/@settings/tabs/roles/RoleMembers.tsx diff --git a/app/components/@settings/tabs/roles/RoleDetails.tsx b/app/components/@settings/tabs/roles/RoleDetails.tsx index ebdfe9a1..b8d8e8aa 100644 --- a/app/components/@settings/tabs/roles/RoleDetails.tsx +++ b/app/components/@settings/tabs/roles/RoleDetails.tsx @@ -1,8 +1,12 @@ import { useEffect, useState } from 'react'; import { resetControlPanelHeader, setControlPanelHeader } from '~/lib/stores/settings'; +import * as ToggleGroup from '@radix-ui/react-toggle-group'; import { toast } from 'sonner'; import type { Role } from './types'; +import RoleMembers from './RoleMembers'; +import { classNames } from '~/utils/classNames'; +const ROLE_TABS = ['Members', 'Environments', 'Data Sources', 'Apps']; interface RoleDetailsProps { role: Role; onBack(): void; @@ -13,6 +17,7 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP const [roleName, setRoleName] = useState(role.name); const [roleDescription, setRoleDescription] = useState(role.description); const [isSaving, setIsSaving] = useState(false); + const [activeTab, setActiveTab] = useState('Members'); useEffect(() => { setControlPanelHeader({ @@ -26,6 +31,19 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP }; }, [role.name]); + const getTabComponent = (tab: string) => { + switch (tab) { + case 'Members': + return ; + case 'Environments': + case 'Data Sources': + case 'Apps': + return
This feature is coming soon!
; + default: + return null; + } + }; + const handleSave = async () => { try { setIsSaving(true); @@ -69,7 +87,7 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP type="text" value={roleName} onChange={(e) => setRoleName(e.target.value)} - className="w-full px-2 py-1 rounded-lg bg-gray-700 border border-gray-600 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + className="w-full px-2 py-1 rounded-lg bg-gray-700 border border-gray-600 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter role name" />
@@ -82,10 +100,41 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP type="text" value={roleDescription} onChange={(e) => setRoleDescription(e.target.value)} - className="w-full px-2 py-1 rounded-lg bg-gray-700 border border-gray-600 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + className="w-full px-2 py-1 rounded-lg bg-gray-700 border border-gray-600 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter role description" />
+
+ { + if (value) { + setActiveTab(value); + } + }} + aria-label="Role tabs" + > + {ROLE_TABS.map((tab) => ( + + {tab} + + ))} + +
+
{getTabComponent(activeTab)}
diff --git a/app/components/@settings/tabs/roles/RoleMembers.tsx b/app/components/@settings/tabs/roles/RoleMembers.tsx new file mode 100644 index 00000000..abf3e314 --- /dev/null +++ b/app/components/@settings/tabs/roles/RoleMembers.tsx @@ -0,0 +1,163 @@ +import { useState, useMemo } from 'react'; +import { CircleMinus, Search, Trash2 } from 'lucide-react'; +import { Dialog, DialogClose, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { classNames } from '~/utils/classNames'; +import { toast } from 'sonner'; +import { useUserStore } from '~/lib/stores/user'; +import type { Role, User } from './types'; + +type RoleMembersProps = { + role: Role; + onRoleUpdate: (updatedRole: Role) => void; +}; + +export default function RoleMembers({ role, onRoleUpdate }: RoleMembersProps) { + const { user } = useUserStore(); + const currentUserId = user?.id; + const [searchQuery, setSearchQuery] = useState(''); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [selectedMember, setSelectedMember] = useState(null); + + const members = role.users || []; + + const handleDeleteMember = async (roleId: string, memberId: string | undefined) => { + if (!memberId) { + return; + } + + setIsDeleting(true); + + try { + const response = await fetch(`/api/roles/${roleId}/users/${memberId}`, { + method: 'DELETE', + }); + const data: { success: boolean; error?: string } = await response.json(); + + if (data.success) { + onRoleUpdate({ + ...role, + users: members.filter((member) => member.id !== memberId), + }); + toast.success(`Member removed from role "${role.name}"`); + } else { + toast.error(data.error || 'Failed to remove member from role'); + } + } catch (error) { + console.error('Error removing member from role:', error); + toast.error('Failed to remove member from role'); + } finally { + setShowDeleteConfirm(false); + setSelectedMember(null); + setIsDeleting(false); + } + }; + + const filteredMembers = useMemo( + () => + members.filter( + (member) => + member.name.toLowerCase().includes(searchQuery.toLowerCase()) || + member.email.toLowerCase().includes(searchQuery.toLowerCase()), + ), + [members, searchQuery], + ); + + return ( +
+
+
+ +
+ setSearchQuery(e.target.value)} + className="w-[566px] h-9 pl-7 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-sm text-white placeholder-gray-400 focus:outline-none" + placeholder="Search..." + /> +
+ +
+
+ Assigned Member +
+ +
+ {filteredMembers.map((member) => ( +
+
+
{member.name}
+
{member.email}
+
+
+ {member.id !== currentUserId && ( + + )} +
+
+ ))} +
+
+ + + +
+
+
+
+
+ +
+
+ +
+
+
+ +
+

+ They will lose access to all resources associated with this role. Are you sure you want to remove + them? +

+
+ +
+ + + + +
+
+
+
+
+
+ ); +} From 580cc55c69ff0b83c9ed03f8b0e3642692a98823 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Thu, 14 Aug 2025 16:48:49 -0500 Subject: [PATCH 07/15] fix handle of tab click on same tab --- app/components/@settings/core/ControlPanel.tsx | 4 ++++ app/components/@settings/tabs/roles/RoleMembers.tsx | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 39384b48..5eec1549 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -150,6 +150,10 @@ export const ControlPanel = () => { }; const handleTabClick = (tabId: TabType) => { + if (activeTab === tabId) { + return; + } + setActiveTab(tabId); // Reset the header when switching tabs diff --git a/app/components/@settings/tabs/roles/RoleMembers.tsx b/app/components/@settings/tabs/roles/RoleMembers.tsx index abf3e314..a03c97f7 100644 --- a/app/components/@settings/tabs/roles/RoleMembers.tsx +++ b/app/components/@settings/tabs/roles/RoleMembers.tsx @@ -39,7 +39,6 @@ export default function RoleMembers({ role, onRoleUpdate }: RoleMembersProps) { ...role, users: members.filter((member) => member.id !== memberId), }); - toast.success(`Member removed from role "${role.name}"`); } else { toast.error(data.error || 'Failed to remove member from role'); } From f4ccc475347f0d531757e3bb7cc162640730508f Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Fri, 15 Aug 2025 12:39:11 -0500 Subject: [PATCH 08/15] fix scrolling in role details --- app/components/@settings/core/ControlPanel.tsx | 2 +- app/components/@settings/tabs/roles/RoleDetails.tsx | 6 +++--- app/components/@settings/tabs/roles/RoleMembers.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 5eec1549..e359c058 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -241,7 +241,7 @@ export const ControlPanel = () => { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} - className="p-6" + className="m-6" > {activeTab ? ( getTabComponent(activeTab) diff --git a/app/components/@settings/tabs/roles/RoleDetails.tsx b/app/components/@settings/tabs/roles/RoleDetails.tsx index b8d8e8aa..ff53b72c 100644 --- a/app/components/@settings/tabs/roles/RoleDetails.tsx +++ b/app/components/@settings/tabs/roles/RoleDetails.tsx @@ -134,10 +134,10 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP ))}
-
{getTabComponent(activeTab)}
+
{getTabComponent(activeTab)}
-
-
+
+
-
+
{filteredMembers.map((member) => (
From 0381654bb3ecd7421888631818d23aa1830f3a6d Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Fri, 15 Aug 2025 15:48:57 -0500 Subject: [PATCH 09/15] added assign role members --- .../tabs/roles/AssignRoleMembers.tsx | 184 ++++++++++++++++++ .../@settings/tabs/roles/RoleDetails.tsx | 22 ++- .../@settings/tabs/roles/RoleMembers.tsx | 82 +++++--- 3 files changed, 256 insertions(+), 32 deletions(-) create mode 100644 app/components/@settings/tabs/roles/AssignRoleMembers.tsx diff --git a/app/components/@settings/tabs/roles/AssignRoleMembers.tsx b/app/components/@settings/tabs/roles/AssignRoleMembers.tsx new file mode 100644 index 00000000..936a2c79 --- /dev/null +++ b/app/components/@settings/tabs/roles/AssignRoleMembers.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect, useMemo } from 'react'; +import { setControlPanelHeader } from '~/lib/stores/settings'; +import { Circle, CircleCheck, Search } from 'lucide-react'; +import { toast } from 'sonner'; +import type { Role, User } from './types'; + +type AssignRoleMembersProps = { + role: Role; + onRoleUpdate: (updatedRole: Role) => void; + closeAssignMembers: () => void; +}; + +export default function AssignRoleMembers({ role, onRoleUpdate, closeAssignMembers }: AssignRoleMembersProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [membersNotInRole, setMembersNotInRole] = useState([]); + const [selectedUsers, setSelectedUsers] = useState>(new Map()); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + setControlPanelHeader({ + title: `Assign members to "${role.name}" role`, + onBack: closeAssignMembers, + }); + + const fetchMembersNotInRole = async () => { + try { + setIsLoading(true); + + const response = await fetch('/api/organization/member'); + const data: { members: User[] } = await response.json(); + + if (data.members) { + const membersNotInRole: User[] = data.members + .map((member) => { + if (!role.users?.some((user) => user.id === member.id)) { + return { id: member.id, name: member.name, email: member.email }; + } + + return null; + }) + .filter((member) => member !== null); + + setMembersNotInRole(membersNotInRole); + } + } catch (error) { + console.error('Error fetching organization members:', error); + toast.error('Failed to fetch organization members'); + } finally { + setIsLoading(false); + } + }; + + fetchMembersNotInRole(); + }, []); + + const handleAssignMembers = async () => { + if (selectedUsers.size === 0) { + return; + } + + setIsSaving(true); + + try { + await Promise.all( + Array.from(selectedUsers.values()).map((user) => + fetch(`/api/roles/${role.id}/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: user.id }), + }).then((res) => { + if (!res.ok) { + const failedUser = membersNotInRole.find((m) => m.id === user.id); + toast.error(`Failed to assign member "${failedUser?.email || 'Unknown'}"`); + selectedUsers.delete(user.id); + } + }), + ), + ); + + const updatedRole = { + ...role, + users: [...(role.users || []), ...Array.from(selectedUsers.values())], + }; + onRoleUpdate(updatedRole); + closeAssignMembers(); + } finally { + setIsSaving(false); + } + }; + + const toggleSelectUser = (user: User) => { + if (!user?.id) { + return; + } + + setSelectedUsers((prev) => { + const newSelected = new Map(prev); + + if (newSelected.has(user.id)) { + newSelected.delete(user.id); + } else { + newSelected.set(user.id, user); + } + + return newSelected; + }); + }; + + const filteredMembers = useMemo( + () => + membersNotInRole.filter( + (member) => + member.name.toLowerCase().includes(searchQuery.toLowerCase()) || + member.email.toLowerCase().includes(searchQuery.toLowerCase()), + ), + [membersNotInRole, searchQuery], + ); + + if (isLoading) { + return ( +
+
Loading members...
+
+ ); + } + + return ( +
+
+
+ +
+ setSearchQuery(e.target.value)} + className="w-full h-9 pl-7 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-sm text-white placeholder-gray-400 focus:outline-none" + placeholder="Search members..." + /> +
+ +
Assign members
+ +
+ {filteredMembers.map((member) => ( +
toggleSelectUser(member)} + > + {selectedUsers.has(member.id) ? ( + + ) : ( + + )} +
+
{member.name}
+
{member.email}
+
+
+ ))} + + {filteredMembers.length === 0 && !isLoading && ( +
No members found
+ )} +
+ +
+
+ +
+
+
+ ); +} diff --git a/app/components/@settings/tabs/roles/RoleDetails.tsx b/app/components/@settings/tabs/roles/RoleDetails.tsx index ff53b72c..02c39be8 100644 --- a/app/components/@settings/tabs/roles/RoleDetails.tsx +++ b/app/components/@settings/tabs/roles/RoleDetails.tsx @@ -4,6 +4,7 @@ import * as ToggleGroup from '@radix-ui/react-toggle-group'; import { toast } from 'sonner'; import type { Role } from './types'; import RoleMembers from './RoleMembers'; +import AssignRoleMembers from './AssignRoleMembers'; import { classNames } from '~/utils/classNames'; const ROLE_TABS = ['Members', 'Environments', 'Data Sources', 'Apps']; @@ -18,6 +19,7 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP const [roleDescription, setRoleDescription] = useState(role.description); const [isSaving, setIsSaving] = useState(false); const [activeTab, setActiveTab] = useState('Members'); + const [showAssignedMembers, setShowAssignedMembers] = useState(false); useEffect(() => { setControlPanelHeader({ @@ -34,7 +36,9 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP const getTabComponent = (tab: string) => { switch (tab) { case 'Members': - return ; + return ( + setShowAssignedMembers(true)} /> + ); case 'Environments': case 'Data Sources': case 'Apps': @@ -76,6 +80,22 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP const isSaveDisabled = isSaving || roleName.trim() === '' || (role.name === roleName && role.description === roleDescription); + if (showAssignedMembers) { + return ( + { + setShowAssignedMembers(false); + setControlPanelHeader({ + title: `Edit "${role.name}"`, + onBack, + }); + }} + /> + ); + } + return (
diff --git a/app/components/@settings/tabs/roles/RoleMembers.tsx b/app/components/@settings/tabs/roles/RoleMembers.tsx index 008fe128..64515876 100644 --- a/app/components/@settings/tabs/roles/RoleMembers.tsx +++ b/app/components/@settings/tabs/roles/RoleMembers.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { CircleMinus, Search, Trash2 } from 'lucide-react'; +import { CircleMinus, Plus, Search, Trash2 } from 'lucide-react'; import { Dialog, DialogClose, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; import { classNames } from '~/utils/classNames'; import { toast } from 'sonner'; @@ -9,9 +9,10 @@ import type { Role, User } from './types'; type RoleMembersProps = { role: Role; onRoleUpdate: (updatedRole: Role) => void; + onAssignMembers: () => void; }; -export default function RoleMembers({ role, onRoleUpdate }: RoleMembersProps) { +export default function RoleMembers({ role, onRoleUpdate, onAssignMembers }: RoleMembersProps) { const { user } = useUserStore(); const currentUserId = user?.id; const [searchQuery, setSearchQuery] = useState(''); @@ -64,17 +65,33 @@ export default function RoleMembers({ role, onRoleUpdate }: RoleMembersProps) { return (
-
-
- +
+
+
+ +
+ setSearchQuery(e.target.value)} + className="w-full h-9 pl-7 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-sm text-white placeholder-gray-400 focus:outline-none" + placeholder="Search members..." + />
- setSearchQuery(e.target.value)} - className="w-[566px] h-9 pl-7 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-sm text-white placeholder-gray-400 focus:outline-none" - placeholder="Search..." - /> +
@@ -82,26 +99,29 @@ export default function RoleMembers({ role, onRoleUpdate }: RoleMembersProps) { Assigned Member
-
- {filteredMembers.map((member) => ( -
-
-
{member.name}
-
{member.email}
-
-
- {member.id !== currentUserId && ( - - )} +
+ {filteredMembers.map((member, index) => ( + <> +
+
+
{member.name}
+
{member.email}
+
+
+ {member.id !== currentUserId && ( + + )} +
-
+ {index < filteredMembers.length - 1 &&
} + ))}
From ede34c0df83cf75530ddb505b80944c40d21cd64 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Fri, 15 Aug 2025 16:35:07 -0500 Subject: [PATCH 10/15] added delete role --- .../@settings/tabs/roles/RoleDetails.tsx | 108 +++++++++++++++++- .../@settings/tabs/roles/RolesTab.tsx | 46 +++++--- 2 files changed, 133 insertions(+), 21 deletions(-) diff --git a/app/components/@settings/tabs/roles/RoleDetails.tsx b/app/components/@settings/tabs/roles/RoleDetails.tsx index 02c39be8..9f9f3e6c 100644 --- a/app/components/@settings/tabs/roles/RoleDetails.tsx +++ b/app/components/@settings/tabs/roles/RoleDetails.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from 'react'; -import { resetControlPanelHeader, setControlPanelHeader } from '~/lib/stores/settings'; import * as ToggleGroup from '@radix-ui/react-toggle-group'; +import { Trash2 } from 'lucide-react'; import { toast } from 'sonner'; +import { resetControlPanelHeader, setControlPanelHeader } from '~/lib/stores/settings'; +import { Dialog, DialogClose, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; import type { Role } from './types'; import RoleMembers from './RoleMembers'; import AssignRoleMembers from './AssignRoleMembers'; @@ -12,14 +14,17 @@ interface RoleDetailsProps { role: Role; onBack(): void; onRoleUpdate: (updatedRole: Role) => void; + onRoleDelete: () => void; } -export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsProps) { +export default function RoleDetails({ role, onBack, onRoleUpdate, onRoleDelete }: RoleDetailsProps) { const [roleName, setRoleName] = useState(role.name); const [roleDescription, setRoleDescription] = useState(role.description); const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const [activeTab, setActiveTab] = useState('Members'); const [showAssignedMembers, setShowAssignedMembers] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); useEffect(() => { setControlPanelHeader({ @@ -77,6 +82,30 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP } }; + const handleDelete = async () => { + try { + setIsDeleting(true); + + const response = await fetch(`/api/roles/${role.id}`, { + method: 'DELETE', + }); + + const data: { success: boolean; error?: string } = await response.json(); + + if (data.success) { + toast.success('Role deleted successfully'); + onRoleDelete(); + } else { + toast.error(data.error || 'Failed to delete role'); + } + } catch (error) { + console.error('Error deleting role:', error); + toast.error('Failed to delete role'); + } finally { + setIsDeleting(false); + } + }; + const isSaveDisabled = isSaving || roleName.trim() === '' || (role.name === roleName && role.description === roleDescription); @@ -157,7 +186,22 @@ export default function RoleDetails({ role, onBack, onRoleUpdate }: RoleDetailsP
{getTabComponent(activeTab)}
-
+
+ +
+ + + +
+
+
+
+
+ +
+
+ +
+
+
+ +
+

+ This action cannot be undone. All permissions associated with this role will be removed from the + members. +

+ {role.users.length > 0 && ( +

+ This role is currently assigned to {role.users.length} member(s). +

+ )} +

Are you sure you want to delete the role "{role.name}"?

+
+ +
+ + + + +
+
+
+
+
); } diff --git a/app/components/@settings/tabs/roles/RolesTab.tsx b/app/components/@settings/tabs/roles/RolesTab.tsx index e4e8ea39..e3578f05 100644 --- a/app/components/@settings/tabs/roles/RolesTab.tsx +++ b/app/components/@settings/tabs/roles/RolesTab.tsx @@ -15,26 +15,26 @@ export default function RolesTab() { const [selectedRole, setSelectedRole] = useState(null); useEffect(() => { - const fetchRoles = async () => { - try { - setIsLoading(true); + fetchRoles(); + }, []); - const response = await fetch('/api/roles'); - const data: LoaderData = await response.json(); + const fetchRoles = async () => { + try { + setIsLoading(true); - if (data.roles) { - setRoles(data.roles); - } - } catch (error) { - console.error('Error fetching roles:', error); - toast.error('Failed to fetch roles'); - } finally { - setIsLoading(false); - } - }; + const response = await fetch('/api/roles'); + const data: LoaderData = await response.json(); - fetchRoles(); - }, []); + if (data.roles) { + setRoles(data.roles); + } + } catch (error) { + console.error('Error fetching roles:', error); + toast.error('Failed to fetch roles'); + } finally { + setIsLoading(false); + } + }; const handleRoleUpdate = (updatedRole: Role) => { setRoles((prevRoles) => prevRoles.map((role) => (role.id === updatedRole.id ? { ...role, ...updatedRole } : role))); @@ -64,7 +64,17 @@ export default function RolesTab() { } if (selectedRole) { - return setSelectedRole(null)} onRoleUpdate={handleRoleUpdate} />; + return ( + setSelectedRole(null)} + onRoleUpdate={handleRoleUpdate} + onRoleDelete={() => { + fetchRoles(); + setSelectedRole(null); + }} + /> + ); } return ( From 9b1ead5bc823164116d99ccb876d04686c4323c3 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Fri, 15 Aug 2025 16:47:54 -0500 Subject: [PATCH 11/15] do not refresh roles on delete --- app/components/@settings/tabs/roles/RoleDetails.tsx | 4 ++-- app/components/@settings/tabs/roles/RolesTab.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/@settings/tabs/roles/RoleDetails.tsx b/app/components/@settings/tabs/roles/RoleDetails.tsx index 9f9f3e6c..b45b16fb 100644 --- a/app/components/@settings/tabs/roles/RoleDetails.tsx +++ b/app/components/@settings/tabs/roles/RoleDetails.tsx @@ -14,7 +14,7 @@ interface RoleDetailsProps { role: Role; onBack(): void; onRoleUpdate: (updatedRole: Role) => void; - onRoleDelete: () => void; + onRoleDelete: (roleId: string) => void; } export default function RoleDetails({ role, onBack, onRoleUpdate, onRoleDelete }: RoleDetailsProps) { @@ -94,7 +94,7 @@ export default function RoleDetails({ role, onBack, onRoleUpdate, onRoleDelete } if (data.success) { toast.success('Role deleted successfully'); - onRoleDelete(); + onRoleDelete(role.id); } else { toast.error(data.error || 'Failed to delete role'); } diff --git a/app/components/@settings/tabs/roles/RolesTab.tsx b/app/components/@settings/tabs/roles/RolesTab.tsx index e3578f05..453c14ec 100644 --- a/app/components/@settings/tabs/roles/RolesTab.tsx +++ b/app/components/@settings/tabs/roles/RolesTab.tsx @@ -69,8 +69,8 @@ export default function RolesTab() { role={selectedRole} onBack={() => setSelectedRole(null)} onRoleUpdate={handleRoleUpdate} - onRoleDelete={() => { - fetchRoles(); + onRoleDelete={(roleId: string) => { + setRoles((prevRoles) => prevRoles.filter((role) => role.id !== roleId)); setSelectedRole(null); }} /> From ce15f64a02c605f54b12d7f3cad9eb9a9a95f0a5 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Fri, 15 Aug 2025 16:50:37 -0500 Subject: [PATCH 12/15] corrected react key placement --- app/components/@settings/tabs/roles/RoleMembers.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/@settings/tabs/roles/RoleMembers.tsx b/app/components/@settings/tabs/roles/RoleMembers.tsx index 64515876..7663022c 100644 --- a/app/components/@settings/tabs/roles/RoleMembers.tsx +++ b/app/components/@settings/tabs/roles/RoleMembers.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import { CircleMinus, Plus, Search, Trash2 } from 'lucide-react'; import { Dialog, DialogClose, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; import { classNames } from '~/utils/classNames'; @@ -101,8 +101,8 @@ export default function RoleMembers({ role, onRoleUpdate, onAssignMembers }: Rol
{filteredMembers.map((member, index) => ( - <> -
+ +
{member.name}
{member.email}
@@ -121,7 +121,7 @@ export default function RoleMembers({ role, onRoleUpdate, onAssignMembers }: Rol
{index < filteredMembers.length - 1 &&
} - +
))}
From 719a87174e38c470f77ed9304692e60610acbe83 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Fri, 15 Aug 2025 17:09:08 -0500 Subject: [PATCH 13/15] feedback fixes --- app/components/@settings/tabs/roles/AssignRoleMembers.tsx | 6 ++++-- app/components/@settings/tabs/roles/RoleDetails.tsx | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/components/@settings/tabs/roles/AssignRoleMembers.tsx b/app/components/@settings/tabs/roles/AssignRoleMembers.tsx index 936a2c79..f940c805 100644 --- a/app/components/@settings/tabs/roles/AssignRoleMembers.tsx +++ b/app/components/@settings/tabs/roles/AssignRoleMembers.tsx @@ -61,6 +61,8 @@ export default function AssignRoleMembers({ role, onRoleUpdate, closeAssignMembe setIsSaving(true); + const newRoleUsers = new Map(selectedUsers); + try { await Promise.all( Array.from(selectedUsers.values()).map((user) => @@ -72,7 +74,7 @@ export default function AssignRoleMembers({ role, onRoleUpdate, closeAssignMembe if (!res.ok) { const failedUser = membersNotInRole.find((m) => m.id === user.id); toast.error(`Failed to assign member "${failedUser?.email || 'Unknown'}"`); - selectedUsers.delete(user.id); + newRoleUsers.delete(user.id); } }), ), @@ -80,7 +82,7 @@ export default function AssignRoleMembers({ role, onRoleUpdate, closeAssignMembe const updatedRole = { ...role, - users: [...(role.users || []), ...Array.from(selectedUsers.values())], + users: [...(role.users || []), ...Array.from(newRoleUsers.values())], }; onRoleUpdate(updatedRole); closeAssignMembers(); diff --git a/app/components/@settings/tabs/roles/RoleDetails.tsx b/app/components/@settings/tabs/roles/RoleDetails.tsx index b45b16fb..51240251 100644 --- a/app/components/@settings/tabs/roles/RoleDetails.tsx +++ b/app/components/@settings/tabs/roles/RoleDetails.tsx @@ -66,7 +66,6 @@ export default function RoleDetails({ role, onBack, onRoleUpdate, onRoleDelete } }); const data: { success: boolean; role: Role; error?: string } = await response.json(); - setIsSaving(false); if (data.success) { onRoleUpdate({ ...role, name: data.role.name, description: data.role.description }); From 4928ea42a7fbfdb54854a192cd269b2eb2630bf2 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Fri, 15 Aug 2025 17:15:23 -0500 Subject: [PATCH 14/15] improved members not in role efficiency --- .../@settings/tabs/roles/AssignRoleMembers.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/components/@settings/tabs/roles/AssignRoleMembers.tsx b/app/components/@settings/tabs/roles/AssignRoleMembers.tsx index f940c805..bf9b829e 100644 --- a/app/components/@settings/tabs/roles/AssignRoleMembers.tsx +++ b/app/components/@settings/tabs/roles/AssignRoleMembers.tsx @@ -31,17 +31,12 @@ export default function AssignRoleMembers({ role, onRoleUpdate, closeAssignMembe const data: { members: User[] } = await response.json(); if (data.members) { - const membersNotInRole: User[] = data.members - .map((member) => { - if (!role.users?.some((user) => user.id === member.id)) { - return { id: member.id, name: member.name, email: member.email }; - } + const roleUserIds = new Set(role.users?.map((user) => user.id)); + const membersNotInRoleList: User[] = data.members + .filter((member) => !roleUserIds.has(member.id)) + .map((member) => ({ id: member.id, name: member.name, email: member.email })); - return null; - }) - .filter((member) => member !== null); - - setMembersNotInRole(membersNotInRole); + setMembersNotInRole(membersNotInRoleList); } } catch (error) { console.error('Error fetching organization members:', error); From e090b5a72abc9b7a6c0b6697495d3deb9ad3a489 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Mon, 18 Aug 2025 09:14:38 -0500 Subject: [PATCH 15/15] fix label --- app/components/@settings/tabs/roles/RoleMembers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/@settings/tabs/roles/RoleMembers.tsx b/app/components/@settings/tabs/roles/RoleMembers.tsx index 7663022c..b8a216bb 100644 --- a/app/components/@settings/tabs/roles/RoleMembers.tsx +++ b/app/components/@settings/tabs/roles/RoleMembers.tsx @@ -96,7 +96,7 @@ export default function RoleMembers({ role, onRoleUpdate, onAssignMembers }: Rol
- Assigned Member + Assigned Members