mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat: Implement complete team management system with role-based access control (#92)
* chore: bump version to 0.34.0 [skip ci] * chore: bump version to 0.35.0 [skip ci] * feat: add .mcp.json for MCP server configuration - Introduced `.mcp.json` with server details for managing MCP integrations - Defines `readme` server with HTTP type and URL endpoint configuration * chore: bump version to 0.36.0 [skip ci] * feat: implement complete team management flow with role-based access control - Add comprehensive team management system with proper role handling - Create TeamManagementPanel for team-specific management hub - Fix role case sensitivity issues (API returns lowercase, UI expects uppercase) - Implement proper team context routing for members and invites panels - Add teams API reference documentation for development reference - Update navigation hooks to support team management routing - Apply consistent max-w-md width constraints across all team panels - Support team switching, creation, joining, and leaving functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve role dropdown case sensitivity issue in TeamMembersPanel - Fix dropdown showing incorrect default role due to case mismatch - API returns lowercase roles ("admin", "member") but UI expects uppercase - Normalize member.role to uppercase in dropdown value and badge display - Ensures dropdown correctly shows actual member role instead of defaulting to first option 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: correct navigation flow from team management members/invites back to team management - Fix route detection to prioritize team management paths over regular team paths - Ensure back button from /team/manage/{id}/members goes to /team/manage/{id} instead of /team - Ensure back button from /team/manage/{id}/invites goes to /team/manage/{id} instead of /team - Replace unreliable document.referrer with proper URL path pattern matching - Add proper hierarchical navigation for team management flow 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add loading states for team members and invites pages - Add separate loading states (isLoadingMembers, isLoadingInvites) to team slice - Display spinner with "Loading members..." message while fetching team members - Display spinner with "Loading invites..." message while fetching team invites - Hide member count and show loading UI during fetch operations - Improve user experience by providing visual feedback during API calls - Ensure proper loading state management in Redux reducers 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * improve: enhance loading UX for team members and invites - Show existing data during refresh instead of hiding everything - Add subtle "Refreshing members..." indicator when data exists - Add subtle "Refreshing invites..." indicator when data exists - Only show full loading screen when no existing data (first load) - Use amber color for refresh indicators to distinguish from main loading - Maintain member/invite count visibility during refresh operations - Significantly better user experience with non-blocking refresh states 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: implement complete team edit and delete functionality - Add Team Settings button with modal to edit team name using updateTeam() API - Add Delete Team button with confirmation modal using deleteTeam() API - Prevent deletion of personal teams (safety check) - Include proper error handling with user-friendly messages - Add loading states for update/delete operations - Modal overlays with proper styling and responsive design - Navigate back to teams list after successful deletion - Refresh teams data after successful name update - Complete the remaining team management API integrations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add confirmation modals for all critical team management actions - Add confirmation modal for removing team members with impact explanation - Add confirmation modal for changing member roles with permission details - Add confirmation modal for revoking invite codes with validation info - Include specific warnings for admin role changes (granting/removing admin rights) - Display invite codes in confirmation modal for clarity - Maintain loading states during confirmation flow - Enhance user safety by preventing accidental critical actions - Follow consistent modal design patterns across all confirmations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add visual indicators for used/expired invite codes - Add status detection for expired and used up invites - Implement visual styling with reduced opacity for inactive invites - Add status badges (Expired/Used Up) next to invite codes - Disable copy button for inactive invites with visual feedback - Restrict revoke button to active invites only - Improve invite code styling based on status 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add confirmation modal for leaving teams - Add confirmation modal for team leave action matching other critical actions - Update handleLeaveTeam to show confirmation instead of immediate action - Add new confirmLeaveTeam function for actual leave operation - Include loading states and proper error handling in leave button - Show warning about losing access and needing new invite to rejoin 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
Steven Enamakel
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
fa5c21d303
commit
561da4a0b4
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"readme": {
|
||||
"type": "http",
|
||||
"url": "https://alphahuman.readme.io/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "alphahuman",
|
||||
"private": true,
|
||||
"version": "0.33.0",
|
||||
"version": "0.36.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "AlphaHuman",
|
||||
"version": "0.33.0",
|
||||
"version": "0.36.0",
|
||||
"identifier": "com.alphahuman.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -15,7 +15,8 @@ export type SettingsRoute =
|
||||
|
||||
interface SettingsNavigationHook {
|
||||
currentRoute: SettingsRoute;
|
||||
navigateToSettings: (route?: SettingsRoute) => void;
|
||||
navigateToSettings: (route?: SettingsRoute | string) => void;
|
||||
navigateToTeamManagement: (teamId: string) => void;
|
||||
navigateBack: () => void;
|
||||
closeSettings: () => void;
|
||||
}
|
||||
@@ -27,6 +28,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
// Determine current settings route from URL
|
||||
const getCurrentRoute = (): SettingsRoute => {
|
||||
const path = location.pathname;
|
||||
// Check specific team management paths first (more specific)
|
||||
if (path.includes('/settings/team/manage/') && path.includes('/members')) return 'team-members';
|
||||
if (path.includes('/settings/team/manage/') && path.includes('/invites')) return 'team-invites';
|
||||
if (path.includes('/settings/team/manage/')) return 'team';
|
||||
// Then check regular team paths (less specific)
|
||||
if (path.includes('/settings/team/members')) return 'team-members';
|
||||
if (path.includes('/settings/team/invites')) return 'team-invites';
|
||||
if (path.includes('/settings/team')) return 'team';
|
||||
@@ -42,7 +48,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
const currentRoute = getCurrentRoute();
|
||||
|
||||
const navigateToSettings = useCallback(
|
||||
(route: SettingsRoute = 'home') => {
|
||||
(route: SettingsRoute | string = 'home') => {
|
||||
if (route === 'home') {
|
||||
navigate('/settings');
|
||||
} else {
|
||||
@@ -52,19 +58,35 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const navigateToTeamManagement = useCallback(
|
||||
(teamId: string) => {
|
||||
navigate(`/settings/team/manage/${teamId}`);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const navigateBack = useCallback(() => {
|
||||
if (currentRoute === 'home') {
|
||||
navigate('/home');
|
||||
} else if (currentRoute === 'team-members' || currentRoute === 'team-invites') {
|
||||
// Check if we're in team management context (has teamId in URL)
|
||||
const teamManageMatch = location.pathname.match(/\/team\/manage\/([^\/]+)/);
|
||||
if (teamManageMatch) {
|
||||
const teamId = teamManageMatch[1];
|
||||
navigate(`/settings/team/manage/${teamId}`);
|
||||
} else {
|
||||
navigate('/settings/team');
|
||||
}
|
||||
} else if (location.pathname.includes('/team/manage/')) {
|
||||
navigate('/settings/team');
|
||||
} else {
|
||||
navigate('/settings');
|
||||
}
|
||||
}, [navigate, currentRoute]);
|
||||
}, [navigate, currentRoute, location.pathname]);
|
||||
|
||||
const closeSettings = useCallback(() => {
|
||||
navigate('/home');
|
||||
}, [navigate]);
|
||||
|
||||
return { currentRoute, navigateToSettings, navigateBack, closeSettings };
|
||||
return { currentRoute, navigateToSettings, navigateToTeamManagement, navigateBack, closeSettings };
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
|
||||
import { teamApi } from '../../../services/api/teamApi';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
@@ -7,31 +8,38 @@ import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const TeamInvitesPanel = () => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const location = useLocation();
|
||||
const { navigateBack } = useSettingsNavigation();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector(state => state.user.user);
|
||||
const { teams, invites } = useAppSelector(state => state.team);
|
||||
const { teams, invites, isLoadingInvites } = useAppSelector(state => state.team);
|
||||
|
||||
const activeTeamId = user?.activeTeamId;
|
||||
const activeTeam = teams.find(t => t.team._id === activeTeamId);
|
||||
const isAdmin = activeTeam?.role === 'ADMIN';
|
||||
// Check if we're in team management context (has teamId in URL)
|
||||
const isInManagementContext = location.pathname.includes('/team/manage/');
|
||||
const currentTeamId = isInManagementContext ? teamId : user?.activeTeamId;
|
||||
const currentTeam = teams.find(t => t.team._id === currentTeamId);
|
||||
const isAdmin = currentTeam?.role.toUpperCase() === 'ADMIN';
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Confirmation modal state
|
||||
const [inviteToRevoke, setInviteToRevoke] = useState<{ id: string; code: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTeamId) dispatch(fetchInvites(activeTeamId));
|
||||
}, [activeTeamId, dispatch]);
|
||||
if (currentTeamId) dispatch(fetchInvites(currentTeamId));
|
||||
}, [currentTeamId, dispatch]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!activeTeamId) return;
|
||||
if (!currentTeamId) return;
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
await teamApi.createInvite(activeTeamId);
|
||||
dispatch(fetchInvites(activeTeamId));
|
||||
await teamApi.createInvite(currentTeamId);
|
||||
dispatch(fetchInvites(currentTeamId));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
@@ -53,13 +61,21 @@ const TeamInvitesPanel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (inviteId: string) => {
|
||||
if (!activeTeamId) return;
|
||||
setRevokingId(inviteId);
|
||||
const handleRevoke = (inviteId: string, inviteCode: string) => {
|
||||
// Show confirmation modal for revoking invites
|
||||
setInviteToRevoke({ id: inviteId, code: inviteCode });
|
||||
};
|
||||
|
||||
const confirmRevokeInvite = async () => {
|
||||
if (!inviteToRevoke || !currentTeamId) return;
|
||||
|
||||
setRevokingId(inviteToRevoke.id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await teamApi.revokeInvite(activeTeamId, inviteId);
|
||||
dispatch(fetchInvites(activeTeamId));
|
||||
await teamApi.revokeInvite(currentTeamId, inviteToRevoke.id);
|
||||
dispatch(fetchInvites(currentTeamId));
|
||||
setInviteToRevoke(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
@@ -73,12 +89,21 @@ const TeamInvitesPanel = () => {
|
||||
|
||||
const isExpired = (expiresAt: string) => new Date(expiresAt) < new Date();
|
||||
|
||||
const isUsedUp = (invite: { maxUses: number; currentUses: number }) =>
|
||||
invite.maxUses > 0 && invite.currentUses >= invite.maxUses;
|
||||
|
||||
const getInviteStatus = (invite: { expiresAt: string; maxUses: number; currentUses: number }) => {
|
||||
if (isExpired(invite.expiresAt)) return 'expired';
|
||||
if (isUsedUp(invite)) return 'used';
|
||||
return 'active';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden flex flex-col h-full">
|
||||
<SettingsHeader title="Invites" showBackButton={true} onBack={navigateBack} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="max-w-md mx-auto p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3">
|
||||
<p className="text-xs text-coral-400">{error}</p>
|
||||
@@ -126,29 +151,93 @@ const TeamInvitesPanel = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Refreshing indicator - only when loading and has existing data */}
|
||||
{isLoadingInvites && invites.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-xs text-amber-400">
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Refreshing invites...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invites list */}
|
||||
{invites.length > 0 ? (
|
||||
{isLoadingInvites && invites.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="w-5 h-5 text-stone-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="ml-3 text-sm text-stone-500">Loading invites...</span>
|
||||
</div>
|
||||
) : invites.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{invites.map(invite => {
|
||||
const expired = isExpired(invite.expiresAt);
|
||||
const status = getInviteStatus(invite);
|
||||
const isInactive = status !== 'active';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={invite._id}
|
||||
className={`rounded-xl border p-3 ${
|
||||
expired
|
||||
isInactive
|
||||
? 'border-stone-700/30 bg-stone-800/20 opacity-60'
|
||||
: 'border-stone-700/50 bg-stone-800/40'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{/* Code */}
|
||||
<code className="text-sm font-mono text-white bg-stone-900/60 px-2 py-1 rounded-lg">
|
||||
{invite.code}
|
||||
</code>
|
||||
{/* Code with status label */}
|
||||
<div className="flex items-center gap-2">
|
||||
<code className={`text-sm font-mono px-2 py-1 rounded-lg ${
|
||||
isInactive
|
||||
? 'text-stone-500 bg-stone-900/30'
|
||||
: 'text-white bg-stone-900/60'
|
||||
}`}>
|
||||
{invite.code}
|
||||
</code>
|
||||
{status === 'expired' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-coral-500/20 text-coral-400 border border-coral-500/30">
|
||||
Expired
|
||||
</span>
|
||||
)}
|
||||
{status === 'used' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
|
||||
Used Up
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={() => handleCopy(invite.code, invite._id)}
|
||||
className="p-1.5 rounded-lg text-stone-400 hover:text-white hover:bg-stone-700/50 transition-colors"
|
||||
disabled={status !== 'active'}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
status === 'active'
|
||||
? 'text-stone-400 hover:text-white hover:bg-stone-700/50'
|
||||
: 'text-stone-600 cursor-not-allowed'
|
||||
}`}
|
||||
aria-label="Copy invite code">
|
||||
{copiedId === invite._id ? (
|
||||
<svg
|
||||
@@ -178,10 +267,10 @@ const TeamInvitesPanel = () => {
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* Revoke */}
|
||||
{isAdmin && !expired && (
|
||||
{/* Revoke - only for active invites */}
|
||||
{isAdmin && status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleRevoke(invite._id)}
|
||||
onClick={() => handleRevoke(invite._id, invite.code)}
|
||||
disabled={revokingId === invite._id}
|
||||
className="p-1.5 rounded-lg text-stone-500 hover:text-coral-400 hover:bg-coral-500/10 transition-colors disabled:opacity-50"
|
||||
aria-label="Revoke invite">
|
||||
@@ -207,7 +296,7 @@ const TeamInvitesPanel = () => {
|
||||
{invite.maxUses > 0 ? `/${invite.maxUses}` : ''}
|
||||
</span>
|
||||
<span>
|
||||
{expired
|
||||
{status === 'expired'
|
||||
? 'Expired'
|
||||
: `Expires ${new Date(invite.expiresAt).toLocaleDateString()}`}
|
||||
</span>
|
||||
@@ -236,6 +325,43 @@ const TeamInvitesPanel = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke Invite Confirmation Modal */}
|
||||
{inviteToRevoke && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-stone-900 rounded-2xl p-6 w-full max-w-md border border-stone-700/50">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Revoke Invite Code</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
|
||||
<p className="text-xs text-coral-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-stone-400">
|
||||
<p>Are you sure you want to revoke the invite code <code className="text-white bg-stone-800/60 px-1.5 py-0.5 rounded font-mono text-xs">{inviteToRevoke.code}</code>?</p>
|
||||
<p className="mt-2 text-amber-400">This invite code will no longer be valid and cannot be used to join the team.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setInviteToRevoke(null)}
|
||||
disabled={revokingId === inviteToRevoke.id}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmRevokeInvite}
|
||||
disabled={revokingId === inviteToRevoke.id}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-coral-500 hover:bg-coral-600 text-white transition-colors disabled:opacity-50">
|
||||
{revokingId === inviteToRevoke.id ? 'Revoking...' : 'Revoke Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { teamApi } from '../../../services/api/teamApi';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { fetchTeams } from '../../../store/teamSlice';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const TeamManagementPanel = () => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const { navigateBack, navigateToSettings } = useSettingsNavigation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { teams } = useAppSelector(state => state.team);
|
||||
|
||||
const teamEntry = teams.find(t => t.team._id === teamId);
|
||||
const isAdmin = teamEntry?.role.toUpperCase() === 'ADMIN';
|
||||
|
||||
// State for edit/delete operations
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [editTeamName, setEditTeamName] = useState('');
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (teams.length === 0) {
|
||||
dispatch(fetchTeams());
|
||||
}
|
||||
}, [dispatch, teams.length]);
|
||||
|
||||
// Redirect if user doesn't have admin access to this team
|
||||
useEffect(() => {
|
||||
if (teamEntry && !isAdmin) {
|
||||
navigateBack();
|
||||
}
|
||||
}, [teamEntry, isAdmin, navigateBack]);
|
||||
|
||||
// Handlers for edit/delete operations
|
||||
const handleEditTeam = () => {
|
||||
setEditTeamName(teamEntry?.team.name || '');
|
||||
setError(null);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdateTeam = async () => {
|
||||
if (!teamId || !editTeamName.trim()) return;
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
try {
|
||||
await teamApi.updateTeam(teamId, { name: editTeamName.trim() });
|
||||
dispatch(fetchTeams());
|
||||
setIsEditModalOpen(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String(err.error)
|
||||
: 'Failed to update team'
|
||||
);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async () => {
|
||||
if (!teamId) return;
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await teamApi.deleteTeam(teamId);
|
||||
navigateBack(); // Navigate back after deletion
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String(err.error)
|
||||
: 'Failed to delete team'
|
||||
);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!teamEntry) {
|
||||
return (
|
||||
<div className="overflow-hidden flex flex-col h-full">
|
||||
<SettingsHeader title="Team Management" showBackButton={true} onBack={navigateBack} />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-stone-500">Team not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="overflow-hidden flex flex-col h-full">
|
||||
<SettingsHeader title="Team Management" showBackButton={true} onBack={navigateBack} />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-stone-500">Access denied</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { team } = teamEntry;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden flex flex-col h-full">
|
||||
<SettingsHeader
|
||||
title={`Manage ${team.name}`}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-md mx-auto p-4 space-y-4">
|
||||
{/* Team Info */}
|
||||
<div className="rounded-xl border border-stone-700/50 bg-stone-800/40 p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-stone-700/60 flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-stone-300">
|
||||
{team.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{team.name}</h3>
|
||||
<p className="text-xs text-stone-500">
|
||||
{team.subscription.plan} Plan • Created {new Date(team.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Options */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1 mb-3">
|
||||
Team Management
|
||||
</h3>
|
||||
|
||||
{/* Members */}
|
||||
<button
|
||||
onClick={() => navigateToSettings(`team/manage/${teamId}/members`)}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-700/50 bg-stone-800/40 hover:bg-stone-800/60 transition-all text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-medium text-sm text-white">Members</div>
|
||||
<p className="text-xs text-stone-500">Manage team members and roles</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Invites */}
|
||||
<button
|
||||
onClick={() => navigateToSettings(`team/manage/${teamId}/invites`)}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-700/50 bg-stone-800/40 hover:bg-stone-800/60 transition-all text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-medium text-sm text-white">Invites</div>
|
||||
<p className="text-xs text-stone-500">Generate and manage invite codes</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Edit Team Settings */}
|
||||
<button
|
||||
onClick={handleEditTeam}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl border border-stone-700/50 bg-stone-800/40 hover:bg-stone-800/60 transition-all text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-medium text-sm text-white">Team Settings</div>
|
||||
<p className="text-xs text-stone-500">Edit team name and settings</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Delete Team */}
|
||||
{!teamEntry?.team.isPersonal && (
|
||||
<button
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl border border-coral-500/30 bg-coral-500/5 hover:bg-coral-500/10 transition-all text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-coral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-medium text-sm text-coral-400">Delete Team</div>
|
||||
<p className="text-xs text-stone-500">Permanently delete this team</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-coral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Team Modal */}
|
||||
{isEditModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-stone-900 rounded-2xl p-6 w-full max-w-md border border-stone-700/50">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Edit Team Settings</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
|
||||
<p className="text-xs text-coral-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-300 mb-2">
|
||||
Team Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editTeamName}
|
||||
onChange={(e) => setEditTeamName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleUpdateTeam()}
|
||||
className="w-full px-3 py-2 text-sm bg-stone-800/60 border border-stone-700/50 rounded-xl text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50"
|
||||
placeholder="Enter team name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
disabled={isUpdating}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateTeam}
|
||||
disabled={isUpdating || !editTeamName.trim()}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50">
|
||||
{isUpdating ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Team Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-stone-900 rounded-2xl p-6 w-full max-w-md border border-stone-700/50">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Delete Team</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
|
||||
<p className="text-xs text-coral-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-stone-400">
|
||||
<p>Are you sure you want to delete <strong className="text-white">{teamEntry?.team.name}</strong>?</p>
|
||||
<p className="mt-2 text-coral-400">This action cannot be undone. All team data will be permanently removed.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteTeam}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-coral-500 hover:bg-coral-600 text-white transition-colors disabled:opacity-50">
|
||||
{isDeleting ? 'Deleting...' : 'Delete Team'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamManagementPanel;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
|
||||
import { teamApi } from '../../../services/api/teamApi';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
@@ -10,30 +11,57 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
const ROLES: TeamRole[] = ['ADMIN', 'BILLING_MANAGER', 'MEMBER'];
|
||||
|
||||
const TeamMembersPanel = () => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const location = useLocation();
|
||||
const { navigateBack } = useSettingsNavigation();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector(state => state.user.user);
|
||||
const { teams, members } = useAppSelector(state => state.team);
|
||||
const { teams, members, isLoadingMembers } = useAppSelector(state => state.team);
|
||||
|
||||
const activeTeamId = user?.activeTeamId;
|
||||
const activeTeam = teams.find(t => t.team._id === activeTeamId);
|
||||
const isAdmin = activeTeam?.role === 'ADMIN';
|
||||
// Check if we're in team management context (has teamId in URL)
|
||||
const isInManagementContext = location.pathname.includes('/team/manage/');
|
||||
const currentTeamId = isInManagementContext ? teamId : user?.activeTeamId;
|
||||
const currentTeam = teams.find(t => t.team._id === currentTeamId);
|
||||
const isAdmin = currentTeam?.role.toUpperCase() === 'ADMIN';
|
||||
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [changingRoleId, setChangingRoleId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTeamId) dispatch(fetchMembers(activeTeamId));
|
||||
}, [activeTeamId, dispatch]);
|
||||
// Confirmation modals state
|
||||
const [memberToRemove, setMemberToRemove] = useState<TeamMember | null>(null);
|
||||
const [roleChangeConfirmation, setRoleChangeConfirmation] = useState<{
|
||||
member: TeamMember;
|
||||
newRole: TeamRole;
|
||||
oldRole: TeamRole;
|
||||
} | null>(null);
|
||||
|
||||
const handleChangeRole = async (member: TeamMember, newRole: TeamRole) => {
|
||||
if (!activeTeamId || member.role === newRole) return;
|
||||
useEffect(() => {
|
||||
if (currentTeamId) dispatch(fetchMembers(currentTeamId));
|
||||
}, [currentTeamId, dispatch]);
|
||||
|
||||
const handleChangeRole = (member: TeamMember, newRole: TeamRole) => {
|
||||
if (!currentTeamId || member.role === newRole) return;
|
||||
|
||||
// Show confirmation modal for role changes
|
||||
setRoleChangeConfirmation({
|
||||
member,
|
||||
newRole,
|
||||
oldRole: member.role as TeamRole,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmChangeRole = async () => {
|
||||
if (!roleChangeConfirmation || !currentTeamId) return;
|
||||
|
||||
const { member, newRole } = roleChangeConfirmation;
|
||||
setChangingRoleId(member._id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await teamApi.changeMemberRole(activeTeamId, member.user._id, newRole);
|
||||
dispatch(fetchMembers(activeTeamId));
|
||||
await teamApi.changeMemberRole(currentTeamId, member.user._id, newRole);
|
||||
dispatch(fetchMembers(currentTeamId));
|
||||
setRoleChangeConfirmation(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
@@ -45,13 +73,21 @@ const TeamMembersPanel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (member: TeamMember) => {
|
||||
if (!activeTeamId) return;
|
||||
setRemovingId(member._id);
|
||||
const handleRemoveMember = (member: TeamMember) => {
|
||||
// Show confirmation modal for removing members
|
||||
setMemberToRemove(member);
|
||||
};
|
||||
|
||||
const confirmRemoveMember = async () => {
|
||||
if (!memberToRemove || !currentTeamId) return;
|
||||
|
||||
setRemovingId(memberToRemove._id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await teamApi.removeMember(activeTeamId, member.user._id);
|
||||
dispatch(fetchMembers(activeTeamId));
|
||||
await teamApi.removeMember(currentTeamId, memberToRemove.user._id);
|
||||
dispatch(fetchMembers(currentTeamId));
|
||||
setMemberToRemove(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
@@ -83,19 +119,63 @@ const TeamMembersPanel = () => {
|
||||
<SettingsHeader title="Members" showBackButton={true} onBack={navigateBack} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="max-w-md mx-auto p-4 space-y-3">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3">
|
||||
<p className="text-xs text-coral-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refreshing indicator - only when loading and has existing data */}
|
||||
{isLoadingMembers && members.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-xs text-amber-400">
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Refreshing members...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member count */}
|
||||
<p className="text-xs text-stone-500 px-1">
|
||||
{members.length} member{members.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{members.map(member => (
|
||||
{/* Full loading state - only when loading and no existing data */}
|
||||
{isLoadingMembers && members.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="w-5 h-5 text-stone-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="ml-3 text-sm text-stone-500">Loading members...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{members.map(member => (
|
||||
<div
|
||||
key={member._id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-stone-700/50 bg-stone-800/40">
|
||||
@@ -125,7 +205,7 @@ const TeamMembersPanel = () => {
|
||||
{/* Role badge / dropdown */}
|
||||
{isAdmin && !isCurrentUser(member) ? (
|
||||
<select
|
||||
value={member.role}
|
||||
value={member.role.toUpperCase()}
|
||||
onChange={e => handleChangeRole(member, e.target.value as TeamRole)}
|
||||
disabled={changingRoleId === member._id}
|
||||
className="px-2 py-1 text-[10px] font-medium rounded-full border bg-stone-800 text-stone-300 border-stone-600 focus:outline-none focus:border-primary-500/50 disabled:opacity-50">
|
||||
@@ -137,8 +217,8 @@ const TeamMembersPanel = () => {
|
||||
</select>
|
||||
) : (
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${roleBadgeColor[member.role] ?? roleBadgeColor.MEMBER}`}>
|
||||
{member.role}
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${roleBadgeColor[member.role.toUpperCase()] ?? roleBadgeColor.MEMBER}`}>
|
||||
{member.role.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -165,12 +245,92 @@ const TeamMembersPanel = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{members.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-stone-500">No members found</p>
|
||||
{members.length === 0 && !isLoadingMembers && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-stone-500">No members found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remove Member Confirmation Modal */}
|
||||
{memberToRemove && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-stone-900 rounded-2xl p-6 w-full max-w-md border border-stone-700/50">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Remove Team Member</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
|
||||
<p className="text-xs text-coral-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-stone-400">
|
||||
<p>Are you sure you want to remove <strong className="text-white">{displayName(memberToRemove)}</strong> from the team?</p>
|
||||
<p className="mt-2 text-coral-400">They will lose access to the team and all team resources.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setMemberToRemove(null)}
|
||||
disabled={removingId === memberToRemove._id}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmRemoveMember}
|
||||
disabled={removingId === memberToRemove._id}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-coral-500 hover:bg-coral-600 text-white transition-colors disabled:opacity-50">
|
||||
{removingId === memberToRemove._id ? 'Removing...' : 'Remove Member'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Role Confirmation Modal */}
|
||||
{roleChangeConfirmation && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-stone-900 rounded-2xl p-6 w-full max-w-md border border-stone-700/50">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Change Member Role</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
|
||||
<p className="text-xs text-coral-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-stone-400">
|
||||
<p>Change <strong className="text-white">{displayName(roleChangeConfirmation.member)}</strong>'s role from <span className="text-amber-400 font-medium">{roleChangeConfirmation.oldRole}</span> to <span className="text-primary-400 font-medium">{roleChangeConfirmation.newRole}</span>?</p>
|
||||
{roleChangeConfirmation.newRole === 'ADMIN' && (
|
||||
<p className="mt-2 text-amber-400">This will grant them full admin permissions including the ability to manage team members.</p>
|
||||
)}
|
||||
{roleChangeConfirmation.oldRole === 'ADMIN' && (
|
||||
<p className="mt-2 text-coral-400">This will remove their admin permissions and they will no longer be able to manage the team.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setRoleChangeConfirmation(null)}
|
||||
disabled={changingRoleId === roleChangeConfirmation.member._id}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmChangeRole}
|
||||
disabled={changingRoleId === roleChangeConfirmation.member._id}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50">
|
||||
{changingRoleId === roleChangeConfirmation.member._id ? 'Changing...' : 'Change Role'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const TeamPanel = () => {
|
||||
const { navigateBack, navigateToSettings } = useSettingsNavigation();
|
||||
const { navigateBack, navigateToTeamManagement } = useSettingsNavigation();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector(state => state.user.user);
|
||||
const { teams, isLoading } = useAppSelector(state => state.team);
|
||||
@@ -19,11 +19,13 @@ const TeamPanel = () => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
const [isSwitching, setIsSwitching] = useState<string | null>(null);
|
||||
const [isLeaving, setIsLeaving] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Confirmation modal state for leaving team
|
||||
const [teamToLeave, setTeamToLeave] = useState<TeamWithRole | null>(null);
|
||||
|
||||
const activeTeamId = user?.activeTeamId;
|
||||
const activeTeamEntry = teams.find(t => t.team._id === activeTeamId);
|
||||
const isAdmin = activeTeamEntry?.role === 'ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTeams());
|
||||
@@ -89,31 +91,55 @@ const TeamPanel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveTeam = async (teamId: string) => {
|
||||
const handleLeaveTeam = (teamEntry: TeamWithRole) => {
|
||||
// Show confirmation modal for leaving teams
|
||||
setTeamToLeave(teamEntry);
|
||||
};
|
||||
|
||||
const confirmLeaveTeam = async () => {
|
||||
if (!teamToLeave) return;
|
||||
|
||||
setIsLeaving(teamToLeave.team._id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await teamApi.leaveTeam(teamId);
|
||||
await teamApi.leaveTeam(teamToLeave.team._id);
|
||||
await dispatch(fetchCurrentUser());
|
||||
dispatch(fetchTeams());
|
||||
setTeamToLeave(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String(err.error)
|
||||
: 'Failed to leave team'
|
||||
);
|
||||
} finally {
|
||||
setIsLeaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const roleBadge = (role: string) => {
|
||||
const roleBadge = (role: string, teamCreatedBy?: string) => {
|
||||
// Normalize role to uppercase for consistent comparison
|
||||
const normalizedRole = role.toUpperCase();
|
||||
|
||||
// Show "Owner" if this is the team creator and admin
|
||||
const isOwner = normalizedRole === 'ADMIN' && teamCreatedBy === user?._id;
|
||||
|
||||
const roleLabel = isOwner ? 'Owner' :
|
||||
normalizedRole === 'ADMIN' ? 'Admin' :
|
||||
normalizedRole === 'BILLING_MANAGER' ? 'Billing Manager' :
|
||||
'Member';
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
ADMIN: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
|
||||
BILLING_MANAGER: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
MEMBER: 'bg-stone-500/20 text-stone-400 border-stone-500/30',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${colors[role] ?? colors.MEMBER}`}>
|
||||
{role}
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${colors[normalizedRole] ?? colors.MEMBER}`}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -135,7 +161,9 @@ const TeamPanel = () => {
|
||||
const TeamRow = ({ entry }: { entry: TeamWithRole }) => {
|
||||
const { team, role } = entry;
|
||||
const isActive = team._id === activeTeamId;
|
||||
const canLeave = !team.isPersonal && role !== 'ADMIN';
|
||||
const normalizedRole = role.toUpperCase();
|
||||
const canLeave = !team.isPersonal && normalizedRole !== 'ADMIN';
|
||||
const canManage = normalizedRole === 'ADMIN' && !team.isPersonal;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -154,7 +182,7 @@ const TeamPanel = () => {
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-white truncate">{team.name}</span>
|
||||
{roleBadge(role)}
|
||||
{roleBadge(role, team.createdBy)}
|
||||
{planBadge(team.subscription.plan)}
|
||||
{isActive && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
|
||||
@@ -167,6 +195,13 @@ const TeamPanel = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => navigateToTeamManagement(team._id)}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 transition-colors">
|
||||
Manage Team
|
||||
</button>
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => handleSwitchTeam(team._id)}
|
||||
@@ -177,9 +212,10 @@ const TeamPanel = () => {
|
||||
)}
|
||||
{canLeave && (
|
||||
<button
|
||||
onClick={() => handleLeaveTeam(team._id)}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg text-amber-400 hover:bg-amber-500/10 transition-colors">
|
||||
Leave
|
||||
onClick={() => handleLeaveTeam(entry)}
|
||||
disabled={isLeaving === team._id}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg text-amber-400 hover:bg-amber-500/10 transition-colors disabled:opacity-50">
|
||||
{isLeaving === team._id ? 'Leaving...' : 'Leave'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -192,7 +228,7 @@ const TeamPanel = () => {
|
||||
<SettingsHeader title="Team" showBackButton={true} onBack={navigateBack} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="max-w-md mx-auto p-4 space-y-4">
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3">
|
||||
@@ -221,140 +257,105 @@ const TeamPanel = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team list */}
|
||||
{/* Teams List - Primary Content */}
|
||||
{teams.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
|
||||
Your Teams ({teams.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{teams.map(entry => (
|
||||
<TeamRow key={entry.team._id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Actions - Secondary Content */}
|
||||
<div className="space-y-4 border-t border-stone-700/50 pt-4">
|
||||
{/* Create team */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
|
||||
Your Teams
|
||||
Create New Team
|
||||
</h3>
|
||||
{teams.map(entry => (
|
||||
<TeamRow key={entry.team._id} entry={entry} />
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamName}
|
||||
onChange={e => setNewTeamName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateTeam()}
|
||||
placeholder="Team name"
|
||||
className="flex-1 px-3 py-2 text-sm bg-stone-800/60 border border-stone-700/50 rounded-xl text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateTeam}
|
||||
disabled={isCreating || !newTeamName.trim()}
|
||||
className="px-4 py-2 text-xs font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isCreating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin actions for active team */}
|
||||
{isAdmin && activeTeamEntry && !activeTeamEntry.team.isPersonal && (
|
||||
<div className="space-y-1">
|
||||
{/* Join team */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
|
||||
Manage Team
|
||||
Join Existing Team
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => navigateToSettings('team-members')}
|
||||
className="w-full flex items-center justify-between p-3 bg-black/50 border-b border-stone-700 hover:bg-stone-800/30 transition-all text-left first:rounded-t-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 opacity-60 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-medium text-sm text-white">Members</div>
|
||||
<p className="text-xs opacity-70">Manage team members and roles</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={joinCode}
|
||||
onChange={e => setJoinCode(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleJoinTeam()}
|
||||
placeholder="Invite code"
|
||||
className="flex-1 px-3 py-2 text-sm bg-stone-800/60 border border-stone-700/50 rounded-xl text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoinTeam}
|
||||
disabled={isJoining || !joinCode.trim()}
|
||||
className="px-4 py-2 text-xs font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isJoining ? 'Joining...' : 'Join'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leave Team Confirmation Modal */}
|
||||
{teamToLeave && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-stone-900 rounded-2xl p-6 w-full max-w-md border border-stone-700/50">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Leave Team</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
|
||||
<p className="text-xs text-coral-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-stone-400">
|
||||
<p>Are you sure you want to leave <strong className="text-white">{teamToLeave.team.name}</strong>?</p>
|
||||
<p className="mt-2 text-amber-400">You will lose access to the team and all team resources. You'll need a new invite to rejoin.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setTeamToLeave(null)}
|
||||
disabled={isLeaving === teamToLeave.team._id}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmLeaveTeam}
|
||||
disabled={isLeaving === teamToLeave.team._id}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-amber-500 hover:bg-amber-600 text-white transition-colors disabled:opacity-50">
|
||||
{isLeaving === teamToLeave.team._id ? 'Leaving...' : 'Leave Team'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToSettings('team-invites')}
|
||||
className="w-full flex items-center justify-between p-3 bg-black/50 hover:bg-stone-800/30 transition-all text-left last:rounded-b-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 opacity-60 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-medium text-sm text-white">Invites</div>
|
||||
<p className="text-xs opacity-70">Generate and manage invite codes</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create team */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
|
||||
Create a Team
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamName}
|
||||
onChange={e => setNewTeamName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateTeam()}
|
||||
placeholder="Team name"
|
||||
className="flex-1 px-3 py-2 text-sm bg-stone-800/60 border border-stone-700/50 rounded-xl text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateTeam}
|
||||
disabled={isCreating || !newTeamName.trim()}
|
||||
className="px-4 py-2 text-xs font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isCreating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Join team */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
|
||||
Join a Team
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={joinCode}
|
||||
onChange={e => setJoinCode(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleJoinTeam()}
|
||||
placeholder="Invite code"
|
||||
className="flex-1 px-3 py-2 text-sm bg-stone-800/60 border border-stone-700/50 rounded-xl text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoinTeam}
|
||||
disabled={isJoining || !joinCode.trim()}
|
||||
className="px-4 py-2 text-xs font-medium rounded-xl bg-stone-700/50 hover:bg-stone-700 text-stone-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isJoining ? 'Joining...' : 'Join'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import MessagingPanel from '../components/settings/panels/MessagingPanel';
|
||||
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
|
||||
import ProfilePanel from '../components/settings/panels/ProfilePanel';
|
||||
import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
|
||||
import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel';
|
||||
import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel';
|
||||
import TeamPanel from '../components/settings/panels/TeamPanel';
|
||||
import SettingsHome from '../components/settings/SettingsHome';
|
||||
@@ -23,6 +24,9 @@ const Settings = () => {
|
||||
<Route path="advanced" element={<AdvancedPanel />} />
|
||||
<Route path="billing" element={<BillingPanel />} />
|
||||
<Route path="team" element={<TeamPanel />} />
|
||||
<Route path="team/manage/:teamId" element={<TeamManagementPanel />} />
|
||||
<Route path="team/manage/:teamId/members" element={<TeamMembersPanel />} />
|
||||
<Route path="team/manage/:teamId/invites" element={<TeamInvitesPanel />} />
|
||||
<Route path="team/members" element={<TeamMembersPanel />} />
|
||||
<Route path="team/invites" element={<TeamInvitesPanel />} />
|
||||
</Routes>
|
||||
|
||||
@@ -8,6 +8,8 @@ interface TeamState {
|
||||
members: TeamMember[];
|
||||
invites: TeamInvite[];
|
||||
isLoading: boolean;
|
||||
isLoadingMembers: boolean;
|
||||
isLoadingInvites: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -16,6 +18,8 @@ const initialState: TeamState = {
|
||||
members: [],
|
||||
invites: [],
|
||||
isLoading: false,
|
||||
isLoadingMembers: false,
|
||||
isLoadingInvites: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -82,22 +86,28 @@ const teamSlice = createSlice({
|
||||
})
|
||||
// fetchMembers
|
||||
.addCase(fetchMembers.pending, state => {
|
||||
state.isLoadingMembers = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchMembers.fulfilled, (state, action) => {
|
||||
state.isLoadingMembers = false;
|
||||
state.members = action.payload;
|
||||
})
|
||||
.addCase(fetchMembers.rejected, (state, action) => {
|
||||
state.isLoadingMembers = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// fetchInvites
|
||||
.addCase(fetchInvites.pending, state => {
|
||||
state.isLoadingInvites = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchInvites.fulfilled, (state, action) => {
|
||||
state.isLoadingInvites = false;
|
||||
state.invites = action.payload;
|
||||
})
|
||||
.addCase(fetchInvites.rejected, (state, action) => {
|
||||
state.isLoadingInvites = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
# AlphaHuman Teams API Reference
|
||||
|
||||
Complete reference for all teams-related API endpoints in the AlphaHuman platform.
|
||||
|
||||
**Base URL**: `https://api.alphahuman.xyz`
|
||||
**Authentication**: Bearer JWT token required for all endpoints
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
---
|
||||
|
||||
## Core Team Management
|
||||
|
||||
### 1. Create Team
|
||||
**POST** `/teams`
|
||||
|
||||
Creates a new team with optional encryption.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "string (required)",
|
||||
"magicWord": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"slug": "string",
|
||||
"magicWord": "string | null",
|
||||
"createdBy": "string",
|
||||
"isPersonal": "boolean",
|
||||
"subscription": {
|
||||
"hasActiveSubscription": "boolean",
|
||||
"plan": "FREE|BASIC|PRO",
|
||||
"planExpiry": "date-time | null",
|
||||
"stripeCustomerId": "string | null"
|
||||
},
|
||||
"usage": {
|
||||
"weeklyBudgetUsd": "number",
|
||||
"spentThisWeekUsd": "number",
|
||||
"weekStartDate": "date-time"
|
||||
},
|
||||
"inviteCode": "string | null",
|
||||
"maxMembers": "number",
|
||||
"createdAt": "date-time",
|
||||
"updatedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Team created successfully
|
||||
- `401` - Unauthorized
|
||||
|
||||
---
|
||||
|
||||
### 2. List Teams
|
||||
**GET** `/teams`
|
||||
|
||||
Retrieves all teams the authenticated user belongs to.
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"team": {
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"slug": "string",
|
||||
"isPersonal": "boolean",
|
||||
"subscription": {
|
||||
"hasActiveSubscription": "boolean",
|
||||
"plan": "FREE|BASIC|PRO"
|
||||
}
|
||||
},
|
||||
"role": "admin|billing_manager|member"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Success
|
||||
- `401` - Unauthorized
|
||||
|
||||
---
|
||||
|
||||
### 3. Get Team Details
|
||||
**GET** `/teams/{teamId}`
|
||||
|
||||
Retrieves detailed information for a specific team.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"slug": "string",
|
||||
"magicWord": "string | null",
|
||||
"createdBy": "string",
|
||||
"isPersonal": "boolean",
|
||||
"subscription": {
|
||||
"hasActiveSubscription": "boolean",
|
||||
"plan": "FREE|BASIC|PRO",
|
||||
"planExpiry": "date-time | null",
|
||||
"stripeCustomerId": "string | null"
|
||||
},
|
||||
"usage": {
|
||||
"weeklyBudgetUsd": "number",
|
||||
"spentThisWeekUsd": "number",
|
||||
"weekStartDate": "date-time"
|
||||
},
|
||||
"inviteCode": "string | null",
|
||||
"maxMembers": "number",
|
||||
"createdAt": "date-time",
|
||||
"updatedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Success
|
||||
- `403` - Not a member of the team
|
||||
- `404` - Team not found
|
||||
|
||||
---
|
||||
|
||||
### 4. Update Team Settings
|
||||
**PUT** `/teams/{teamId}`
|
||||
|
||||
Updates team settings. **Admin only**.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "string (optional)",
|
||||
"maxMembers": "number (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Team updated successfully
|
||||
- `403` - Only admins can update team settings
|
||||
|
||||
---
|
||||
|
||||
### 5. Delete Team
|
||||
**DELETE** `/teams/{teamId}`
|
||||
|
||||
Deletes a team. **Admin only, non-personal teams only**.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Team deleted successfully
|
||||
- `400` - Cannot delete a personal team
|
||||
- `403` - Only admins can delete a team
|
||||
|
||||
---
|
||||
|
||||
### 6. Switch Active Team
|
||||
**POST** `/teams/{teamId}/switch`
|
||||
|
||||
Changes the user's active team.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Successfully switched active team
|
||||
- `403` - Not a member of the specified team
|
||||
|
||||
---
|
||||
|
||||
## Team Member Management
|
||||
|
||||
### 7. List Team Members
|
||||
**GET** `/teams/{teamId}/members`
|
||||
|
||||
Lists all members of a team.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"user": "string",
|
||||
"role": "admin|billing_manager|member",
|
||||
"joinedAt": "date-time"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Success
|
||||
- `403` - Not a member of this team
|
||||
|
||||
---
|
||||
|
||||
### 8. Remove Team Member
|
||||
**DELETE** `/teams/{teamId}/members/{userId}`
|
||||
|
||||
Removes a member from the team. **Admin only**.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
- `userId` (path, required) - User identifier to remove
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Member removed
|
||||
- `403` - Only admins can remove members
|
||||
|
||||
---
|
||||
|
||||
### 9. Change Member Role
|
||||
**PUT** `/teams/{teamId}/members/{userId}/role`
|
||||
|
||||
Changes a member's role within the team. **Admin only**.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
- `userId` (path, required) - User identifier
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"role": "admin|billing_manager|member"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Role updated
|
||||
- `403` - Only admins can change member roles
|
||||
|
||||
---
|
||||
|
||||
### 10. Leave Team
|
||||
**POST** `/teams/{teamId}/leave`
|
||||
|
||||
Allows a user to leave a team.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Successfully left the team
|
||||
- `400` - Cannot leave as the only admin
|
||||
|
||||
---
|
||||
|
||||
## Team Invite Management
|
||||
|
||||
### 11. Create Team Invite
|
||||
**POST** `/teams/{teamId}/invites`
|
||||
|
||||
Creates a new invite code for the team.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Request Body (Optional):**
|
||||
```json
|
||||
{
|
||||
"maxUses": "number (default: 1)",
|
||||
"expiresInDays": "number (default: 7)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"code": "string (e.g., T-1A2B3C4D5E6F)",
|
||||
"expiresAt": "date-time",
|
||||
"maxUses": "number"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Invite created successfully
|
||||
- `403` - Not a team member
|
||||
|
||||
---
|
||||
|
||||
### 12. List Team Invites
|
||||
**GET** `/teams/{teamId}/invites`
|
||||
|
||||
Lists all invites for a team.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Success
|
||||
- `403` - User is not a member of the team
|
||||
|
||||
---
|
||||
|
||||
### 13. Revoke Team Invite
|
||||
**DELETE** `/teams/{teamId}/invites/{inviteId}`
|
||||
|
||||
Revokes a team invite. **Admin or invite creator only**.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
- `inviteId` (path, required) - Invite identifier
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Invite successfully revoked
|
||||
- `403` - Only admins or invite creator can revoke
|
||||
- `404` - Invite does not exist
|
||||
|
||||
---
|
||||
|
||||
### 14. Join Team
|
||||
**POST** `/teams/join`
|
||||
|
||||
Joins a team using an invite code.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"code": "string (required, e.g., T-1A2B3C4D5E6F)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"team": "string",
|
||||
"membership": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Joined the team successfully
|
||||
- `400` - Invite expired, max uses reached, or already a team member
|
||||
- `404` - Invite code not found
|
||||
|
||||
---
|
||||
|
||||
## Team Billing Management
|
||||
|
||||
### 15. Purchase Team Subscription
|
||||
**POST** `/teams/{teamId}/billing/purchase`
|
||||
|
||||
Purchases a subscription plan for the team. **Admin or billing manager only**.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"plan": "BASIC_MONTHLY|BASIC_YEARLY|PRO_MONTHLY|PRO_YEARLY",
|
||||
"successUrl": "string (optional)",
|
||||
"cancelUrl": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Checkout session created successfully
|
||||
- `403` - Only admins or billing managers can purchase plans
|
||||
|
||||
---
|
||||
|
||||
### 16. Get Team Subscription Plan
|
||||
**GET** `/teams/{teamId}/billing/plan`
|
||||
|
||||
Retrieves the team's current subscription information.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"plan": "FREE|BASIC|PRO",
|
||||
"hasActiveSubscription": "boolean",
|
||||
"planExpiry": "date-time | null"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Success
|
||||
- `401` - Unauthorized
|
||||
|
||||
---
|
||||
|
||||
### 17. Create Billing Portal Session
|
||||
**POST** `/teams/{teamId}/billing/portal`
|
||||
|
||||
Creates a Stripe billing portal session for subscription management. **Admin or billing manager only**.
|
||||
|
||||
**Parameters:**
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Request Body (Optional):**
|
||||
```json
|
||||
{
|
||||
"returnUrl": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"url": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200` - Portal session created successfully
|
||||
- `403` - Only admins or billing managers can create portal session
|
||||
|
||||
---
|
||||
|
||||
## Team Roles
|
||||
|
||||
### Role Hierarchy
|
||||
1. **admin** - Full team management permissions
|
||||
2. **billing_manager** - Billing and subscription management
|
||||
3. **member** - Basic team member access
|
||||
|
||||
### Permission Matrix
|
||||
|
||||
| Action | Admin | Billing Manager | Member |
|
||||
|--------|-------|-----------------|--------|
|
||||
| View team details | ✅ | ✅ | ✅ |
|
||||
| Update team settings | ✅ | ❌ | ❌ |
|
||||
| Delete team | ✅ | ❌ | ❌ |
|
||||
| Add/remove members | ✅ | ❌ | ❌ |
|
||||
| Change member roles | ✅ | ❌ | ❌ |
|
||||
| Create invites | ✅ | ✅ | ✅ |
|
||||
| Manage billing | ✅ | ✅ | ❌ |
|
||||
| Leave team | ✅* | ✅ | ✅ |
|
||||
|
||||
*Admin cannot leave if they are the only admin
|
||||
|
||||
---
|
||||
|
||||
## Team Plans & Limits
|
||||
|
||||
### Plan Types
|
||||
- **FREE** - Basic team functionality
|
||||
- **BASIC** - Enhanced features and limits
|
||||
- **PRO** - Full feature set and highest limits
|
||||
|
||||
### Usage Tracking
|
||||
Teams have usage limits tracked through:
|
||||
- `weeklyBudgetUsd` - Weekly spending budget
|
||||
- `spentThisWeekUsd` - Current week spending
|
||||
- `weekStartDate` - When the current week started
|
||||
- `maxMembers` - Maximum team size
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Responses
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error message description"
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
All endpoints require a Bearer JWT token:
|
||||
```
|
||||
Authorization: Bearer <your-jwt-token>
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
Standard API rate limits apply to all endpoints. Refer to main API documentation for specific limits.
|
||||
Reference in New Issue
Block a user