import { useCallback, useEffect, useMemo, useState } from 'react'; import { WizardStep } from './wizard-step'; import { TeamRightPane, type TeamMemberSummary } from './wizard-right-panes'; import { EmployeeCreateForm, emptyEmployeeCreateFormValues, generateTempPassword, type EmployeeCreateFormValues, type RoleOption, } from '@/components/forms/employee-create-form'; import { Button } from '@/components/base/buttons/button'; import { apiClient } from '@/lib/api-client'; import { notify } from '@/lib/toast'; import { useAuth } from '@/providers/auth-provider'; import type { WizardStepComponentProps } from './wizard-step-types'; // Team step (post-rework) — creates workspace members directly from // the portal via the sidecar's /api/team/members endpoint. The admin // enters name + email + temp password + role. SIP seat assignment is // NOT done here — it lives exclusively in the Telephony wizard step // so admins manage one thing in one place. // // Edit mode: clicking the pencil icon on an employee row in the right // pane loads that member back into the form (name + role only — email, // password and SIP seat are not editable here). Save in edit mode // fires PUT /api/team/members/:id instead of POST. // // Email invitations are NOT used anywhere in this flow. The admin is // expected to share the temp password with the employee directly. // Recently-created employees keep their plaintext password in // component state so the right pane's copy icon can paste a // shareable credentials block to the clipboard. Page reload clears // that state — only employees created in the current session show // the copy icon. Older members get only the edit icon. // In-memory record of an employee the admin just created in this // session. Holds the plaintext temp password so the copy-icon flow // works without ever sending the password back from the server. type CreatedMemberRecord = { id: string; userEmail: string; firstName: string; lastName: string; roleId: string; tempPassword: string; }; type RoleRow = { id: string; label: string; description: string | null; canBeAssignedToUsers: boolean; }; type AgentRow = { id: string; name: string | null; sipExtension: string | null; ozonetelAgentId: string | null; workspaceMemberId: string | null; workspaceMember: { id: string; name: { firstName: string | null; lastName: string | null } | null; userEmail: string; } | null; }; type WorkspaceMemberRow = { id: string; userEmail: string; name: { firstName: string | null; lastName: string | null } | null; // Platform returns `null` (not an empty array) for members with no // role assigned — touching `.roles[0]` directly throws. Always // optional-chain reads. roles: { id: string; label: string }[] | null; }; const AI_EMAIL_SUFFIX = '@ai.fortytwo.local'; // Build the credentials block that gets copied to the clipboard. Two // lines (login url + email) plus the temp password — formatted so // the admin can paste it straight into WhatsApp / SMS. Login URL is // derived from the current browser origin since the wizard is always // loaded from the workspace's own URL (or Vite dev), so this matches // what the employee will use. const buildCredentialsBlock = (email: string, tempPassword: string): string => { const origin = typeof window !== 'undefined' ? window.location.origin : ''; return `Login: ${origin}/login\nEmail: ${email}\nTemporary password: ${tempPassword}`; }; export const WizardStepTeam = (props: WizardStepComponentProps) => { const { user } = useAuth(); const currentUserEmail = user?.email ?? null; // Initialise the form with a fresh temp password so the admin // doesn't have to click "regenerate" before saving the very first // employee. const [values, setValues] = useState(() => ({ ...emptyEmployeeCreateFormValues, password: generateTempPassword(), })); const [editingMemberId, setEditingMemberId] = useState(null); const [roles, setRoles] = useState([]); // Agents are still fetched (even though we don't show a SIP seat // picker here) because the right-pane summary needs each member's // current SIP extension to show the green badge. const [agents, setAgents] = useState([]); const [members, setMembers] = useState([]); const [createdMembers, setCreatedMembers] = useState([]); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true); const isEditing = editingMemberId !== null; const fetchRolesAndAgents = useCallback(async () => { try { const data = await apiClient.graphql<{ getRoles: RoleRow[]; agents: { edges: { node: AgentRow }[] }; workspaceMembers: { edges: { node: WorkspaceMemberRow }[] }; }>( `{ getRoles { id label description canBeAssignedToUsers } agents(first: 100) { edges { node { id name sipExtension ozonetelAgentId workspaceMemberId workspaceMember { id name { firstName lastName } userEmail } } } } workspaceMembers(first: 200) { edges { node { id userEmail name { firstName lastName } roles { id label } } } } }`, undefined, { silent: true }, ); const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers); setRoles( assignable.map((r) => ({ id: r.id, label: r.label, supportingText: r.description ?? undefined, })), ); setAgents(data.agents.edges.map((e) => e.node)); setMembers( data.workspaceMembers.edges .map((e) => e.node) .filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)), ); } catch (err) { console.error('[wizard/team] fetch roles/agents failed', err); } finally { setLoading(false); } }, []); useEffect(() => { fetchRolesAndAgents(); }, [fetchRolesAndAgents]); // Reset form back to a fresh "create" state with a new auto-gen // password. Used after both create-success and edit-cancel. const resetForm = () => { setEditingMemberId(null); setValues({ ...emptyEmployeeCreateFormValues, password: generateTempPassword(), }); }; const handleSaveCreate = async () => { const firstName = values.firstName.trim(); const email = values.email.trim(); if (!firstName) { notify.error('First name is required'); return; } if (!email) { notify.error('Email is required'); return; } if (!values.password) { notify.error('Temporary password is required'); return; } if (!values.roleId) { notify.error('Pick a role'); return; } setSaving(true); try { const created = await apiClient.post<{ id: string; userEmail: string; firstName: string; lastName: string; roleId: string; }>('/api/team/members', { firstName, lastName: values.lastName.trim(), email, password: values.password, roleId: values.roleId, }); // Stash the plaintext temp password alongside the created // member so the copy-icon can build a credentials block // later. The password is NOT sent back from the server — // we hold the only copy in this component's memory. setCreatedMembers((prev) => [ ...prev, { ...created, tempPassword: values.password }, ]); notify.success( 'Employee created', `${firstName} ${values.lastName.trim()}`.trim() || email, ); await fetchRolesAndAgents(); resetForm(); if (!props.isCompleted) { await props.onComplete('team'); } } catch (err) { console.error('[wizard/team] create failed', err); } finally { setSaving(false); } }; const handleSaveUpdate = async () => { if (!editingMemberId) return; const firstName = values.firstName.trim(); if (!firstName) { notify.error('First name is required'); return; } if (!values.roleId) { notify.error('Pick a role'); return; } setSaving(true); try { await apiClient.put(`/api/team/members/${editingMemberId}`, { firstName, lastName: values.lastName.trim(), roleId: values.roleId, }); notify.success( 'Employee updated', `${firstName} ${values.lastName.trim()}`.trim() || values.email, ); await fetchRolesAndAgents(); resetForm(); } catch (err) { console.error('[wizard/team] update failed', err); } finally { setSaving(false); } }; const handleSave = isEditing ? handleSaveUpdate : handleSaveCreate; // Right-pane edit handler — populate the form with the picked // member's data and switch into edit mode. Email is preserved as // the row's email (read-only in edit mode); password is cleared // since the form hides the field anyway. const handleEditMember = (memberId: string) => { const member = members.find((m) => m.id === memberId); if (!member) return; const firstRole = member.roles?.[0] ?? null; setEditingMemberId(memberId); setValues({ firstName: member.name?.firstName ?? '', lastName: member.name?.lastName ?? '', email: member.userEmail, password: '', roleId: firstRole?.id ?? '', }); }; // Right-pane copy handler — build the shareable credentials block // and put it on the clipboard. Only fires for members in the // createdMembers in-memory map; rows without a known temp password // don't show the icon at all. const handleCopyCredentials = async (memberId: string) => { const member = members.find((m) => m.id === memberId); if (!member) return; // Three-tier fallback: // 1. In-browser memory (createdMembers state) — populated when // the admin created this employee in the current session, // survives until refresh. Fastest path, no network call. // 2. Sidecar Redis cache via GET /api/team/members/:id/temp-password // — populated for any member created via this endpoint // within the last 24h, survives reloads. // 3. Cache miss → tell the admin the password is no longer // recoverable and direct them to the platform reset flow. const fromMemory = createdMembers.find( (c) => c.userEmail.toLowerCase() === member.userEmail.toLowerCase(), ) ?? createdMembers.find((c) => c.id === memberId); let tempPassword = fromMemory?.tempPassword ?? null; if (!tempPassword) { try { const res = await apiClient.get<{ password: string | null }>( `/api/team/members/${memberId}/temp-password`, { silent: true }, ); tempPassword = res.password; } catch (err) { console.error('[wizard/team] temp-password fetch failed', err); } } if (!tempPassword) { notify.error( 'Password unavailable', 'The temp password expired (>24h). Reset the password from settings to mint a new one.', ); return; } const block = buildCredentialsBlock(member.userEmail, tempPassword); try { await navigator.clipboard.writeText(block); notify.success('Copied', 'Credentials copied to clipboard'); } catch (err) { console.error('[wizard/team] clipboard write failed', err); notify.error('Copy failed', 'Could not write to clipboard'); } }; // Trick: we lie to WizardStep about isCompleted so that once at // least one employee exists, the primary wizard button flips to // "Continue" and the create form stays available below for more // adds. const pretendCompleted = props.isCompleted || members.length > 0 || createdMembers.length > 0; // Build the right pane summary. Every non-admin row gets the // copy icon — `canCopyCredentials: true` unconditionally — and // the click handler figures out at action time whether to read // from in-browser memory or the sidecar's Redis cache. If both // are empty (>24h old), the click toasts a "password expired" // message instead of silently failing. const teamSummaries = useMemo( () => members.map((m) => { const seat = agents.find((a) => a.workspaceMemberId === m.id); const firstRole = m.roles?.[0] ?? null; return { id: m.id, userEmail: m.userEmail, name: m.name, roleLabel: firstRole?.label ?? null, sipExtension: seat?.sipExtension ?? null, isCurrentUser: currentUserEmail !== null && m.userEmail === currentUserEmail, canCopyCredentials: true, }; }), [members, agents, currentUserEmail], ); return ( } > {loading ? (

Loading team settings…

) : (
{isEditing ? (

Editing an existing employee. You can change their name and role. To change their SIP seat, go to the Telephony step.

) : (

Create employees in-place. Each person gets an auto-generated temporary password that you share directly — no email invitations are sent. Click the eye icon to reveal it before you save. After creating CC agents, head to the Telephony{' '} step to assign them SIP seats.

)}
{isEditing && ( )}
)}
); };