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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user