'use client' /** * TeamSettingsPage — shared composer for /settings/team across every app * in the monorepo (startsim-o7s). * * Wires: * - useAuth (current user + logout) * - useMembershipFromApi (company + currentTeam + isAdmin) * - useInvitations (pending invitations) * - api.teams.members / updateRole / removeMember (members table) * - LeaveTeamDialog (calls api.teams.removeMember on the current user) * * Apps pass: * - api: the @startsimpli/api client (structurally typed below) * - centralAuthApp: app slug for buildCentralAuthUrl (e.g. 'vault') * - classNames: optional per-slot Tailwind overrides * - onLeftTeam: optional override; defaults to logout + central-auth bounce */ import * as React from 'react' import { useAuth, useInvitations, useMembershipFromApi, buildCentralAuthUrl, } from '@startsimpli/auth' import { MembersTable } from '../MembersTable' import { InviteMemberDialog } from '../InviteMemberDialog' import { LeaveTeamDialog } from '../LeaveTeamDialog' import type { MemberRow, TeamRole, InvitationLite, } from '../types' import { TEAM_SETTINGS_PAGE_DEFAULTS, type TeamSettingsPageClassNames, } from './team-settings-page-default-class-names' import type { TeamSettingsApi, ApiInvitation, ApiMember, } from './types' export interface TeamSettingsPageProps { /** The @startsimpli/api client. */ api: TeamSettingsApi /** Central-auth app slug (e.g. 'vault', 'market', 'raise'). */ centralAuthApp: string /** Per-slot className overrides. */ classNames?: TeamSettingsPageClassNames /** * Optional override for the post-leave handler. Defaults to logout + * redirect to central auth. Tested-only override path; most apps don't * need to set it. */ onLeftTeam?: () => void /** * Optional href used by the back link. Defaults to '/'. Apps can point at * their dashboard route ('/environments', '/dashboard', etc.). */ backHref?: string /** Text shown for the back link. Defaults to '← Back'. */ backLabel?: string /** Title shown in the header. Defaults to 'Team & members'. */ title?: string } /** Human-friendly relative-ish expiry. Keeps the wire untouched. */ function formatExpiry(iso: string): string { try { const d = new Date(iso) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } catch { return iso } } /** Render the backend's 400 "last owner" error in a friendly way. */ function friendlyRoleError(err: unknown): string { if (err instanceof Error && /owner/i.test(err.message)) { return 'A team must always have at least one owner.' } return err instanceof Error ? err.message : 'Could not update role.' } function toInvitationLite(i: ApiInvitation): InvitationLite { return { id: i.id, email: i.email, teamId: i.teamId, role: i.role, token: i.token, expiresAt: i.expiresAt, acceptedAt: i.acceptedAt, revokedAt: i.revokedAt, isExpired: i.isExpired, isAccepted: i.isAccepted, } } export function TeamSettingsPage({ api, centralAuthApp, classNames, onLeftTeam, backHref = '/', backLabel = '← Back', title = 'Team & members', }: TeamSettingsPageProps) { const cls = { ...TEAM_SETTINGS_PAGE_DEFAULTS, ...(classNames ?? {}) } const { user, logout } = useAuth() const membership = useMembershipFromApi(api) const { company, currentTeam, isAdmin, isLoading, error, refresh } = membership const teamId = currentTeam?.id const teamSlug = currentTeam?.slug ?? teamId const [members, setMembers] = React.useState([]) const [membersLoading, setMembersLoading] = React.useState(false) const [membersError, setMembersError] = React.useState('') const [mutationError, setMutationError] = React.useState('') const loadMembers = React.useCallback(async () => { if (!teamSlug) return setMembersLoading(true) setMembersError('') try { const rows: ApiMember[] = await api.teams.members(teamSlug) setMembers( rows.map((m) => ({ id: m.id, userId: m.userId, teamId: m.teamId, role: m.role, joinedAt: m.joinedAt, user: m.user, })), ) } catch (err) { setMembersError(err instanceof Error ? err.message : 'Could not load members') } finally { setMembersLoading(false) } }, [api, teamSlug]) React.useEffect(() => { void loadMembers() }, [loadMembers]) const invitations = useInvitations({ fetchInvitations: async () => { if (!teamSlug) return [] const res = await api.teamInvitations.list({ teamId: teamSlug }) return res.results.map((i) => ({ id: i.id, email: i.email, teamId: i.teamId, role: i.role, expiresAt: i.expiresAt, acceptedAt: i.acceptedAt, revokedAt: i.revokedAt, isExpired: i.isExpired, isAccepted: i.isAccepted, createdAt: i.createdAt, })) }, revokeInvitation: async (id) => { await api.teamInvitations.revoke(id) }, bulkInviteToTeam: async (slug, invites) => { const result = await api.teams.bulkInvite(slug, invites) return { invited: result.invited.map((i) => ({ id: i.id, email: i.email, teamId: i.teamId, role: i.role, expiresAt: i.expiresAt, acceptedAt: i.acceptedAt, revokedAt: i.revokedAt, isExpired: i.isExpired, isAccepted: i.isAccepted, createdAt: i.createdAt, })), skipped: result.skipped, } }, autoFetch: false, }) React.useEffect(() => { if (teamSlug) void invitations.refresh() // eslint-disable-next-line react-hooks/exhaustive-deps }, [teamSlug]) async function handleChangeRole(userId: string, role: TeamRole) { if (!teamSlug) return setMutationError('') const snapshot = members setMembers((prev) => prev.map((m) => (m.userId === userId ? { ...m, role } : m))) try { await api.teams.updateRole(teamSlug, userId, role) } catch (err) { setMembers(snapshot) setMutationError(friendlyRoleError(err)) } } async function handleRemove(userId: string) { if (!teamSlug) return setMutationError('') const snapshot = members setMembers((prev) => prev.filter((m) => m.userId !== userId)) try { await api.teams.removeMember(teamSlug, userId) } catch (err) { setMembers(snapshot) setMutationError(friendlyRoleError(err)) } } async function handleLeave() { if (!teamSlug || !user) return await api.teams.removeMember(teamSlug, user.id) } function defaultOnLeftTeam() { void logout().finally(() => { if (typeof window !== 'undefined') { window.location.href = buildCentralAuthUrl('signin', { app: centralAuthApp, returnTo: `${window.location.origin}/`, }) } }) } // ----- Render ----------------------------------------------------------- if (isLoading) { return

Loading team…

} if (error) { return (

Could not load team membership.

) } if (!currentTeam) { return (
{backLabel}

{title}

You aren't a member of any team yet. Accept an invitation or ask an admin to add you.

) } const handleInvited = () => { void invitations.refresh() } return (
{backLabel}

{title}

{company ? `${company.name} · ` : ''} {currentTeam.name}

{/* Members --------------------------------------------------------- */}

Members

{isAdmin && teamId && ( api.teamInvitations .create({ email: input.email, teamId: input.teamId, role: input.role, }) .then(toInvitationLite) } onInvited={handleInvited} /> )}
{mutationError && (

{mutationError}

)} {membersError && (

{membersError}

)} {membersLoading ? (

Loading members…

) : ( )}
{/* Pending invitations --------------------------------------------- */}

Pending invitations

{invitations.isLoading ? (

Loading invitations…

) : invitations.error ? (

Could not load invitations.

) : invitations.pending.length === 0 ? (

No pending invitations.

) : (
{invitations.pending.map((inv) => ( ))}
Email Role Expires Actions
{inv.email} {inv.role} {formatExpiry(inv.expiresAt)} {isAdmin && ( )}
)}
{/* Leave team ------------------------------------------------------- */}

Danger zone

Leave this team. You'll need a fresh invitation to rejoin.

) }