feat: invitar a un proyecto por correo a alguien que aun no es usuario

Al agregar un miembro, el popup ahora permite elegir un usuario existente
o escribir un correo nuevo. Si ese correo no pertenece a nadie, se crea
una cuenta en estado 'invited' (nombre/clave placeholder) y se le manda
un correo con un enlace de un solo uso para que complete su registro
(elige su nombre y clave). La fila de project_members se crea de una vez
al invitar, asi que en cuanto acepta ya es miembro sin pasos adicionales.
A diferencia del autorregistro publico, no pasa por aprobacion de un
admin -- quien lo agrego a su proyecto ya lo vetó.

Nuevos endpoints publicos GET /api/auth/invite y POST /api/auth/accept-invite.
Nueva pagina AcceptInvitePage (/aceptar-invitacion). Migracion 004 agrega
el status 'invited' al enum de users.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-09 23:55:02 -05:00
parent 53c7bd7f95
commit d7478d0f21
20 changed files with 320 additions and 23 deletions
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserPlus } from 'lucide-react';
import { useAuth } from '../../context/auth-context.jsx';
import { useUi } from '../../context/ui-context.jsx';
import { getInviteInfo, acceptInvite } from '../../services/auth.js';
import { Field } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
import logoDark from '../../assets/logoLinkedCorpTxt.png';
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
export default function AcceptInvitePage() {
const { setSession } = useAuth();
const { t, theme } = useUi();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') || '';
const requestedTokenRef = useRef(null);
const [state, setState] = useState('loading'); // loading | ready | error
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!token) { setState('error'); setError(t('accept_invite_missing_token')); return; }
if (requestedTokenRef.current === token) return;
requestedTokenRef.current = token;
getInviteInfo(token)
.then((info) => { setEmail(info.email); setState('ready'); })
.catch((err) => { setState('error'); setError(err.message || t('common_error')); });
}, [token, t]);
async function handleSubmit(e) {
e.preventDefault();
setError('');
if (password !== confirm) { setError(t('register_password_mismatch')); return; }
if (password.length < 8) { setError(t('register_password_short')); return; }
setLoading(true);
try {
const result = await acceptInvite(token, name, password);
setSession(result.token, result.user);
navigate('/', { replace: true });
} catch (err) {
setError(err.message || t('common_error'));
} finally {
setLoading(false);
}
}
return (
<div className="login-shell">
<form className="login-card" onSubmit={handleSubmit}>
<div className="login-logo">
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
</div>
<div className="login-title">{t('accept_invite_title')}</div>
{error && <div className="login-error">{error}</div>}
{state === 'loading' && <div className="text-muted">{t('common_loading')}</div>}
{state === 'ready' && (
<>
<div className="text-muted" style={{ fontSize: '.8rem', marginBottom: 10 }}>{t('accept_invite_hint')}</div>
<Field label={t('login_email')}>
<input className="input" value={email} disabled />
</Field>
<Field label={t('register_name')}>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
</Field>
<Field label={t('login_password')}>
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} required />
</Field>
<Field label={t('register_password_confirm')}>
<PasswordInput value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</Field>
<Button variant="accent" icon={UserPlus} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
{t('accept_invite_submit')}
</Button>
</>
)}
<div className="login-links">
<Link to="/login">{t('register_back_to_login')}</Link>
</div>
</form>
</div>
);
}
@@ -13,24 +13,31 @@ import { DetailPopup } from '../../components/DetailPopup.jsx';
const ROLE_BADGE = { owner: { color: 'accent', key: 'project_role_owner' }, admin: { color: 'success', key: 'project_role_admin' } };
export default function ProjectMembersTab({ project, onChanged }) {
const { t, openDialog } = useUi();
const { t, openDialog, pushToast } = useUi();
const runAction = useAction();
const { data: users } = useAsync(listUsers, []);
const [addingMember, setAddingMember] = useState('');
const [inviteEmail, setInviteEmail] = useState('');
const [addAsAdmin, setAddAsAdmin] = useState(false);
const [addMemberOpen, setAddMemberOpen] = useState(false);
const userOptions = (users || []).filter((u) => !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name }));
const canAdd = !!addingMember || !!inviteEmail.trim();
async function addMember() {
if (!addingMember) return;
if (!canAdd) return;
await runAction(async () => {
await addProjectMember(project.id, Number(addingMember), addAsAdmin ? 'admin' : null);
const target = inviteEmail.trim()
? { email: inviteEmail.trim() }
: { userId: Number(addingMember) };
const result = await addProjectMember(project.id, target, addAsAdmin ? 'admin' : null);
setAddingMember('');
setInviteEmail('');
setAddAsAdmin(false);
setAddMemberOpen(false);
onChanged();
pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') });
});
}
@@ -82,16 +89,29 @@ export default function ProjectMembersTab({ project, onChanged }) {
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
</div>
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={300}>
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={380}>
<Field label={t('project_add_member')}>
<ComboBox options={userOptions} value={addingMember} onChange={setAddingMember} placeholder={t('project_add_member')} />
<ComboBox
options={userOptions}
value={addingMember}
onChange={(v) => { setAddingMember(v); setInviteEmail(''); }}
placeholder={t('project_add_member')}
/>
</Field>
<div className="text-muted" style={{ fontSize: '.76rem', margin: '8px 0', textAlign: 'center' }}>{t('common_or')}</div>
<Field label={t('project_invite_email')} hint={t('project_invite_email_hint')}>
<input
className="input" type="email" value={inviteEmail}
onChange={(e) => { setInviteEmail(e.target.value); setAddingMember(''); }}
placeholder="correo@dominio.com"
/>
</Field>
<label className="checkbox-row">
<input type="checkbox" checked={addAsAdmin} onChange={(e) => setAddAsAdmin(e.target.checked)} />
{t('project_add_member_as_admin')}
</label>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={UserPlus} onClick={addMember}>{t('common_save')}</Button>
<Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
</div>
</DetailPopup>