diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..837d7163f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "readme": { + "type": "http", + "url": "https://alphahuman.readme.io/mcp" + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index c76e94153..f7f7571b1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "alphahuman", "private": true, - "version": "0.33.0", + "version": "0.36.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1b1aefb72..6c0f1940b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/settings/hooks/useSettingsNavigation.ts b/src/components/settings/hooks/useSettingsNavigation.ts index 20bc177d0..105e0f355 100644 --- a/src/components/settings/hooks/useSettingsNavigation.ts +++ b/src/components/settings/hooks/useSettingsNavigation.ts @@ -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 }; }; diff --git a/src/components/settings/panels/TeamInvitesPanel.tsx b/src/components/settings/panels/TeamInvitesPanel.tsx index 6ba5cf6b7..0aa93ebe2 100644 --- a/src/components/settings/panels/TeamInvitesPanel.tsx +++ b/src/components/settings/panels/TeamInvitesPanel.tsx @@ -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(null); const [revokingId, setRevokingId] = useState(null); const [error, setError] = useState(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 (
-
+
{error && (

{error}

@@ -126,29 +151,93 @@ const TeamInvitesPanel = () => { )} + {/* Refreshing indicator - only when loading and has existing data */} + {isLoadingInvites && invites.length > 0 && ( +
+ + + + + Refreshing invites... +
+ )} + {/* Invites list */} - {invites.length > 0 ? ( + {isLoadingInvites && invites.length === 0 ? ( +
+ + + + + Loading invites... +
+ ) : invites.length > 0 ? (
{invites.map(invite => { - const expired = isExpired(invite.expiresAt); + const status = getInviteStatus(invite); + const isInactive = status !== 'active'; + return (
- {/* Code */} - - {invite.code} - + {/* Code with status label */} +
+ + {invite.code} + + {status === 'expired' && ( + + Expired + + )} + {status === 'used' && ( + + Used Up + + )} +
{/* Copy */} - {/* Revoke */} - {isAdmin && !expired && ( + {/* Revoke - only for active invites */} + {isAdmin && status === 'active' && (
)} + + {/* Revoke Invite Confirmation Modal */} + {inviteToRevoke && ( +
+
+

Revoke Invite Code

+ + {error && ( +
+

{error}

+
+ )} + +
+
+

Are you sure you want to revoke the invite code {inviteToRevoke.code}?

+

This invite code will no longer be valid and cannot be used to join the team.

+
+ +
+ + +
+
+
+
+ )}
diff --git a/src/components/settings/panels/TeamManagementPanel.tsx b/src/components/settings/panels/TeamManagementPanel.tsx new file mode 100644 index 000000000..8e0d773c2 --- /dev/null +++ b/src/components/settings/panels/TeamManagementPanel.tsx @@ -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(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 ( +
+ +
+

Team not found

+
+
+ ); + } + + if (!isAdmin) { + return ( +
+ +
+

Access denied

+
+
+ ); + } + + const { team } = teamEntry; + + return ( +
+ + +
+
+ {/* Team Info */} +
+
+
+ + {team.name.charAt(0).toUpperCase()} + +
+
+

{team.name}

+

+ {team.subscription.plan} Plan • Created {new Date(team.createdAt).toLocaleDateString()} +

+
+
+
+ + {/* Management Options */} +
+

+ Team Management +

+ + {/* Members */} + + + {/* Invites */} + + + {/* Edit Team Settings */} + + + {/* Delete Team */} + {!teamEntry?.team.isPersonal && ( + + )} +
+ + {/* Edit Team Modal */} + {isEditModalOpen && ( +
+
+

Edit Team Settings

+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + 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" + /> +
+ +
+ + +
+
+
+
+ )} + + {/* Delete Team Modal */} + {isDeleteModalOpen && ( +
+
+

Delete Team

+ + {error && ( +
+

{error}

+
+ )} + +
+
+

Are you sure you want to delete {teamEntry?.team.name}?

+

This action cannot be undone. All team data will be permanently removed.

+
+ +
+ + +
+
+
+
+ )} +
+
+
+ ); +}; + +export default TeamManagementPanel; \ No newline at end of file diff --git a/src/components/settings/panels/TeamMembersPanel.tsx b/src/components/settings/panels/TeamMembersPanel.tsx index ce20e0485..8e91dcaae 100644 --- a/src/components/settings/panels/TeamMembersPanel.tsx +++ b/src/components/settings/panels/TeamMembersPanel.tsx @@ -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(null); const [changingRoleId, setChangingRoleId] = useState(null); const [error, setError] = useState(null); - useEffect(() => { - if (activeTeamId) dispatch(fetchMembers(activeTeamId)); - }, [activeTeamId, dispatch]); + // Confirmation modals state + const [memberToRemove, setMemberToRemove] = useState(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 = () => {
-
+
{error && (

{error}

)} + {/* Refreshing indicator - only when loading and has existing data */} + {isLoadingMembers && members.length > 0 && ( +
+ + + + + Refreshing members... +
+ )} + + {/* Member count */}

{members.length} member{members.length !== 1 ? 's' : ''}

-
- {members.map(member => ( + {/* Full loading state - only when loading and no existing data */} + {isLoadingMembers && members.length === 0 ? ( +
+ + + + + Loading members... +
+ ) : ( +
+ {members.map(member => (
@@ -125,7 +205,7 @@ const TeamMembersPanel = () => { {/* Role badge / dropdown */} {isAdmin && !isCurrentUser(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()} )} @@ -165,12 +245,92 @@ const TeamMembersPanel = () => { )}
- ))} -
+ ))} - {members.length === 0 && ( -
-

No members found

+ {members.length === 0 && !isLoadingMembers && ( +
+

No members found

+
+ )} +
+ )} + + {/* Remove Member Confirmation Modal */} + {memberToRemove && ( +
+
+

Remove Team Member

+ + {error && ( +
+

{error}

+
+ )} + +
+
+

Are you sure you want to remove {displayName(memberToRemove)} from the team?

+

They will lose access to the team and all team resources.

+
+ +
+ + +
+
+
+
+ )} + + {/* Change Role Confirmation Modal */} + {roleChangeConfirmation && ( +
+
+

Change Member Role

+ + {error && ( +
+

{error}

+
+ )} + +
+
+

Change {displayName(roleChangeConfirmation.member)}'s role from {roleChangeConfirmation.oldRole} to {roleChangeConfirmation.newRole}?

+ {roleChangeConfirmation.newRole === 'ADMIN' && ( +

This will grant them full admin permissions including the ability to manage team members.

+ )} + {roleChangeConfirmation.oldRole === 'ADMIN' && ( +

This will remove their admin permissions and they will no longer be able to manage the team.

+ )} +
+ +
+ + +
+
+
)}
diff --git a/src/components/settings/panels/TeamPanel.tsx b/src/components/settings/panels/TeamPanel.tsx index 14d8c810f..03e9ed63f 100644 --- a/src/components/settings/panels/TeamPanel.tsx +++ b/src/components/settings/panels/TeamPanel.tsx @@ -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(null); + const [isLeaving, setIsLeaving] = useState(null); const [error, setError] = useState(null); + // Confirmation modal state for leaving team + const [teamToLeave, setTeamToLeave] = useState(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 = { 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 ( - {role} + className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${colors[normalizedRole] ?? colors.MEMBER}`}> + {roleLabel} ); }; @@ -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 (
{
{team.name} - {roleBadge(role)} + {roleBadge(role, team.createdBy)} {planBadge(team.subscription.plan)} {isActive && ( @@ -167,6 +195,13 @@ const TeamPanel = () => {
+ {canManage && ( + + )} {!isActive && ( )}
@@ -192,7 +228,7 @@ const TeamPanel = () => {
-
+
{/* Error banner */} {error && (
@@ -221,140 +257,105 @@ const TeamPanel = () => {
)} - {/* Team list */} + {/* Teams List - Primary Content */} {teams.length > 0 && ( +
+

+ Your Teams ({teams.length}) +

+
+ {teams.map(entry => ( + + ))} +
+
+ )} + + {/* Team Actions - Secondary Content */} +
+ {/* Create team */}

- Your Teams + Create New Team

- {teams.map(entry => ( - - ))} +
+ 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" + /> + +
- )} - {/* Admin actions for active team */} - {isAdmin && activeTeamEntry && !activeTeamEntry.team.isPersonal && ( -
+ {/* Join team */} +

- Manage Team + Join Existing Team

- +
+
+
+ + {/* Leave Team Confirmation Modal */} + {teamToLeave && ( +
+
+

Leave Team

+ + {error && ( +
+

{error}

+
+ )} + +
+
+

Are you sure you want to leave {teamToLeave.team.name}?

+

You will lose access to the team and all team resources. You'll need a new invite to rejoin.

+
+ +
+ +
- - - - - +
)} - - {/* Create team */} -
-

- Create a Team -

-
- 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" - /> - -
-
- - {/* Join team */} -
-

- Join a Team -

-
- 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" - /> - -
-
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e13e3a54c..4821ebd28 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/src/store/teamSlice.ts b/src/store/teamSlice.ts index 1094889bd..429842321 100644 --- a/src/store/teamSlice.ts +++ b/src/store/teamSlice.ts @@ -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; }); }, diff --git a/teams-api-reference.md b/teams-api-reference.md new file mode 100644 index 000000000..fd3d09764 --- /dev/null +++ b/teams-api-reference.md @@ -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 +``` + +### Rate Limiting +Standard API rate limits apply to all endpoints. Refer to main API documentation for specific limits. \ No newline at end of file