Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions app/components/@settings/core/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,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';

Expand Down Expand Up @@ -148,8 +150,15 @@ export const ControlPanel = () => {
};

const handleTabClick = (tabId: TabType) => {
if (activeTab === tabId) {
return;
}

setActiveTab(tabId);

// Reset the header when switching tabs
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a reason why we're doing this? To add more emphasis and to prevent somebody from deleting this line without reason.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add in the next PR.

resetControlPanelHeader();

// Store the selected tab
localStorage.setItem(LAST_ACCESSED_TAB_KEY, tabId);
};
Expand All @@ -166,6 +175,8 @@ export const ControlPanel = () => {
return <OrganizationTab />;
case 'members':
return <MembersTab />;
case 'roles':
return <RolesTab />;
default:
return null;
}
Expand Down Expand Up @@ -221,21 +232,16 @@ export const ControlPanel = () => {

{/* Main Content */}
<div className="flex-1 flex flex-col min-w-[650px]">
<ControlPanelHeader />

<div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900 relative">
<div className="absolute top-4 right-4 z-10">
<CloseCircle
variant="Bold"
className="w-6 h-6 text-gray-500 dark:text-white hover:text-gray-700 dark:hover:text-gray-400 transition-colors cursor-pointer"
onClick={handleClose}
/>
</div>
<motion.div
key={activeTab || 'home'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-6 pt-16"
className="m-6"
>
{activeTab ? (
getTabComponent(activeTab)
Expand Down
43 changes: 43 additions & 0 deletions app/components/@settings/core/ControlPanelHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={classNames(
'flex items-center justify-between h-16 px-6 bg-gray-50 dark:bg-gray-900 flex-shrink-0',
header.onBack || header.title ? 'border-b border-gray-700/50' : '',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{header.onBack && (
<button
onClick={header.onBack}
className="p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer transition-colors"
aria-label="Go back"
>
<ArrowLeft className="w-5 h-5" />
</button>
)}
{header.title && (
<h2 className="text-base font-semibold text-gray-900 dark:text-white truncate">{header.title}</h2>
)}
</div>
</div>
<div className="pl-4">
<CloseCircle
variant="Bold"
className="w-6 h-6 text-gray-500 dark:text-white hover:text-gray-700 dark:hover:text-gray-400 transition-colors cursor-pointer"
onClick={closeSettingsPanel}
/>
</div>
</div>
);
};
6 changes: 5 additions & 1 deletion app/components/@settings/core/constants.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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<TabType, string | LucideIcon> = {
data: Database,
github: GitBranch,
'deployed-apps': Rocket,
organization: Building,
members: Users,
roles: ShieldUser,
};

export const TAB_LABELS: Record<TabType, string> = {
Expand All @@ -15,6 +16,7 @@ export const TAB_LABELS: Record<TabType, string> = {
'deployed-apps': 'Deployed Apps',
organization: 'Organization',
members: 'Members',
roles: 'Roles',
};

export const TAB_DESCRIPTIONS: Record<TabType, string> = {
Expand All @@ -23,6 +25,7 @@ export const TAB_DESCRIPTIONS: Record<TabType, string> = {
'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[] = [
Expand All @@ -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 },
];
3 changes: 2 additions & 1 deletion app/components/@settings/core/types.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -30,4 +30,5 @@ export const TAB_LABELS: Record<TabType, string> = {
'deployed-apps': 'Deployed Apps',
organization: 'Organization',
members: 'Members',
roles: 'Roles',
};
181 changes: 181 additions & 0 deletions app/components/@settings/tabs/roles/AssignRoleMembers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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<User[]>([]);
const [selectedUsers, setSelectedUsers] = useState<Map<string, User>>(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 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 }));

setMembersNotInRole(membersNotInRoleList);
}
} catch (error) {
console.error('Error fetching organization members:', error);
toast.error('Failed to fetch organization members');
} finally {
setIsLoading(false);
}
};

fetchMembersNotInRole();
}, []);
Comment thread
bears4barrett marked this conversation as resolved.

const handleAssignMembers = async () => {
if (selectedUsers.size === 0) {
return;
}

setIsSaving(true);

const newRoleUsers = new Map(selectedUsers);

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'}"`);
newRoleUsers.delete(user.id);
}
}),
),
);

const updatedRole = {
...role,
users: [...(role.users || []), ...Array.from(newRoleUsers.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 (
<div className="flex items-center justify-center p-8">
<div className="text-gray-400">Loading members...</div>
</div>
);
}

return (
<div>
<div className="relative flex-1 mb-3">
<div className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center pointer-events-none">
<Search className="w-4 h-4 text-gray-400" />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => 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..."
/>
</div>

<div className="text-sm text-gray-400 px-4 py-2 border-b border-gray-700">Assign members</div>

<div className="min-h-106">
{filteredMembers.map((member) => (
<div
key={member.id}
className="flex items-center gap-6 p-4 border-b border-gray-700/50 cursor-pointer"
onClick={() => toggleSelectUser(member)}
>
{selectedUsers.has(member.id) ? (
<CircleCheck className="w-5 h-5 text-accent-500" />
) : (
<Circle className="w-5 h-5 text-gray-600 dark:text-gray-400" />
)}
<div className="space-y-1">
<div className="text-sm text-white">{member.name}</div>
<div className="text-sm text-gray-400">{member.email}</div>
</div>
</div>
))}

{filteredMembers.length === 0 && !isLoading && (
<div className="p-4 text-sm text-gray-400">No members found</div>
)}
</div>

<div className="sticky bottom-0 left-0 right-0 -m-6 border-t border-gray-800 bg-gray-50 dark:bg-gray-900">
<div className="flex justify-end p-4">
<button
onClick={handleAssignMembers}
disabled={isSaving || selectedUsers.size === 0}
className="px-4 py-2 bg-accent-500 hover:bg-accent-600 text-gray-950 dark:text-gray-950 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving
? 'Assigning Members...'
: `Assign ${selectedUsers.size ? selectedUsers.size : ''} Member${selectedUsers.size !== 1 ? 's' : ''}`}
</button>
</div>
</div>
</div>
);
}
Loading
Loading