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:
Cyrus Gray
2026-02-10 16:58:49 +05:30
committed by GitHub
co-authored by Claude Steven Enamakel github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent fa5c21d303
commit 561da4a0b4
11 changed files with 1403 additions and 196 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"readme": {
"type": "http",
"url": "https://alphahuman.readme.io/mcp"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "alphahuman",
"private": true,
"version": "0.33.0",
"version": "0.36.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -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>
+137 -136
View File
@@ -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>
+4
View File
@@ -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>
+10
View File
@@ -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;
});
},
+501
View File
@@ -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.