GuestOps/web/src/TeamPage.tsx
2026-09-30 10:24:27 +01:00

32 lines
9.4 KiB
TypeScript

import { useEffect, useState } from 'react';
import { api } from './api';
import { hotelTime } from './time';
type Member={id:string;name:string;email:string;role:string;legacyRole:boolean;active:boolean;pending:boolean;mfaEnabled:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null};
type Link={userId:string;link?:string;deliveryState:string;expiresAt:string};
export function TeamPage({owner,actorRole,timeZone}:{owner:boolean;actorRole:string;timeZone:string}) {
const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[role,setRole]=useState('Agent'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false);
async function refresh(){setMembers(await api<Member[]>('/team'));}
useEffect(()=>{if(owner)refresh().catch(e=>setError(e.message));},[owner]);
async function act(path:string,body:unknown,method='POST'){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,method,body);if(result?.deliveryState)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
if(!owner)return <div className="page"><h1>Team access</h1><p>Your hotel owner manages staff accounts.</p></div>;
return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">A place for everyone</span><h1>Your team</h1><p>Give each colleague their own access to the hotel workspace.</p></div>{error&&<div className="alert" role="alert">{error}</div>}
{link&&<section className="settings-card account-link" aria-label="Account delivery"><h2>{link.link?'Share this preview link privately':'Account email queued'}</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>Delivery state: {link.deliveryState}. Expires {hotelTime(link.expiresAt,timeZone)}.</p>{link.link&&<><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link||'');setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></>}</section>}
<section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Roles are enforced by the server. Managers may manage Agent and Auditor accounts only.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email,role});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label><label>Role<select value={role} onChange={e=>setRole(e.target.value)}>{actorRole==='Owner'&&<option>Manager</option>}<option>Agent</option><option>Auditor</option></select></label></div><div className="form-actions"><button className="button primary">Send invitation</button></div></fieldset></form></section>
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=>{const manageable=m.role!=='Owner'&&(actorRole==='Owner'||m.role==='Agent'||m.role==='Auditor');return <article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role}{m.legacyRole?' (legacy Staff)':''} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'} · MFA {m.mfaEnabled?'on':'off'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {hotelTime(m.linkExpiresAt!,timeZone)}</p>}</div>{manageable&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email,role:m.role})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.active&&<select aria-label={`Role for ${m.name}`} value={m.role} onChange={e=>act(`/team/${m.id}/role`,{version:m.version,role:e.target.value},'PUT')}>{actorRole==='Owner'&&<option>Manager</option>}<option>Agent</option><option>Auditor</option></select>}{actorRole==='Owner'&&m.mfaEnabled&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Reset MFA for ${m.name} after completing identity verification?`))void act(`/team/${m.id}/mfa-reset`,{version:m.version});}}>Reset MFA</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>;})}</div><p className="small muted">New links invalidate earlier links. Password, role, disablement and MFA changes invalidate affected sessions. Owner MFA recovery is available only through the audited server command.</p></section>
</div>;
}
type Setup={preview:boolean;steps:{title:string;detail:string;path:string;complete:boolean;optional:boolean}[]};
export function OnboardingPage({owner,go}:{owner:boolean;go:(path:string)=>void}){
const [data,setData]=useState<Setup|null>(null),[error,setError]=useState('');
useEffect(()=>{if(owner)api<Setup>('/onboarding').then(setData).catch(e=>setError(e.message));},[owner]);
if(!owner)return <div className="page"><h1>Hotel setup</h1><p>Your hotel owner manages setup.</p></div>;
return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">Start with the essentials</span><h1>Welcome to GuestOps</h1><p>Set up a workspace your team can rely on, one step at a time.</p></div>{error&&<div className="alert" role="alert">{error}</div>}{!data&&!error&&<p>Checking hotel setup…</p>}{data&&<><div className="knowledge-summary"><div><strong>{data.steps.filter(s=>!s.optional&&s.complete).length} of {data.steps.filter(s=>!s.optional).length} essentials ready</strong><p>{data.preview?'Sample workspace: real mailbox connections are unavailable.':'Progress reflects saved hotel settings and connections. Review each item before your team starts work.'}</p></div></div>{data.steps.map((s,i)=><section className="settings-card setup-step" key={s.title}><span className={'setup-number '+(s.complete?'complete':'')}>{s.complete?'✓':i+1}</span><div><h2>{s.title}</h2><p>{s.detail}</p><span className="small muted">{s.complete?'Ready to review':s.optional?'Optional next step':'Needs setup'}</span></div><button className="button secondary" onClick={()=>go(s.path)}>{s.complete?'Review':'Open'}</button></section>)}<p className="small muted">This checklist does not activate email sending, PMS changes or payments. Each feature keeps its own approval controls.</p></>}</div>;
}
export function AccountPage(){
const [token]=useState(()=>{const value=new URLSearchParams(location.hash.slice(1)).get('token')||'';history.replaceState({},'',location.pathname);return value;}),[info,setInfo]=useState<{name:string;email:string;hotelName:string;purpose:string}|null>(null),[error,setError]=useState(''),[password,setPassword]=useState(''),[confirmPassword,setConfirm]=useState(''),[busy,setBusy]=useState(false),[done,setDone]=useState(false);
useEffect(()=>{if(!token){setError('Open the original invitation or recovery link. If it has expired, ask for a new one.');return;}api<typeof info>('/account-links/inspect','POST',{token}).then(setInfo).catch(e=>setError(e.message));},[token]);
return <div className="account-layout"><section className="settings-card"><a className="brand" href="/">guestops.</a><h1>{done?'Your account is ready':info?.purpose==='Invite'?'Join your hotel team':'Set your password'}</h1>{done?<><p>Your password has been saved and previous sessions have ended.</p><a className="button primary" href="/">Continue to sign in</a></>:<>{error&&<div className="alert" role="alert">{error}</div>}{info&&<><p>{info.name} · {info.hotelName}</p><p className="muted">{info.email}</p><form onSubmit={async e=>{e.preventDefault();if(busy)return;setBusy(true);setError('');try{await api('/account-links/accept','POST',{token,password,confirmPassword});setPassword('');setConfirm('');setDone(true);}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}}><label>New password<input type="password" autoComplete="new-password" required minLength={14} maxLength={128} value={password} onChange={e=>setPassword(e.target.value)}/></label><label>Confirm password<input type="password" autoComplete="new-password" required minLength={14} maxLength={128} value={confirmPassword} onChange={e=>setConfirm(e.target.value)}/></label><p className="small muted">Use a unique password of 14 to 128 characters.</p><button className="button primary wide" disabled={busy}>{busy?'Saving…':'Save password'}</button></form></>}<p><a href="/">Back to sign in</a></p></>}</section></div>;
}