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:
@@ -10,6 +10,7 @@ import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
||||
import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx';
|
||||
import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx';
|
||||
import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx';
|
||||
import AcceptInvitePage from '../pages/Login/AcceptInvitePage.jsx';
|
||||
import DashboardPage from '../pages/Dashboard/DashboardPage.jsx';
|
||||
import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx';
|
||||
import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
|
||||
@@ -27,6 +28,7 @@ export default function App() {
|
||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
|
||||
@@ -25,6 +25,10 @@ export const messages = {
|
||||
verify_email_title: 'Verificar correo',
|
||||
verify_email_success: 'Correo confirmado. Tu cuenta esta pendiente de aprobacion por un administrador.',
|
||||
verify_email_missing_token: 'Enlace invalido: falta el token de verificacion.',
|
||||
accept_invite_title: 'Completar registro',
|
||||
accept_invite_hint: 'Te invitaron a un proyecto. Elige tu nombre y una clave para crear tu cuenta.',
|
||||
accept_invite_submit: 'Crear mi cuenta',
|
||||
accept_invite_missing_token: 'Enlace invalido: falta el token de invitacion.',
|
||||
forgot_password_title: 'Recuperar clave',
|
||||
forgot_password_submit: 'Enviar instrucciones',
|
||||
forgot_password_sent: 'Si el correo existe, te enviamos instrucciones para recuperar tu clave.',
|
||||
@@ -72,6 +76,7 @@ export const messages = {
|
||||
common_view: 'Ver',
|
||||
common_yes: 'Si',
|
||||
common_no: 'No',
|
||||
common_or: 'o',
|
||||
common_loading: 'Cargando...',
|
||||
common_empty: 'No hay datos para mostrar',
|
||||
common_confirm_delete_title: 'Confirmar eliminacion',
|
||||
@@ -127,6 +132,9 @@ export const messages = {
|
||||
project_role_admin: 'Administrador',
|
||||
project_make_admin: 'Hacer administrador',
|
||||
project_remove_admin: 'Quitar administrador',
|
||||
project_invite_email: 'Invitar por correo',
|
||||
project_invite_email_hint: 'Si el correo no pertenece a nadie todavia, se le manda un enlace para que complete su registro y se una al proyecto.',
|
||||
project_invite_sent: 'Invitacion enviada por correo',
|
||||
project_created_at: 'Creado',
|
||||
project_deleted_at: 'Eliminado',
|
||||
project_delete_confirm_msg: '¿Seguro que deseas eliminar este proyecto? Se movera a la papelera y dejara de verse en el listado; un superadministrador puede restaurarlo o borrarlo definitivamente.',
|
||||
@@ -225,6 +233,10 @@ export const messages = {
|
||||
verify_email_title: 'Verify email',
|
||||
verify_email_success: 'Email confirmed. Your account is pending administrator approval.',
|
||||
verify_email_missing_token: 'Invalid link: missing verification token.',
|
||||
accept_invite_title: 'Complete your registration',
|
||||
accept_invite_hint: 'You were invited to a project. Choose your name and a password to create your account.',
|
||||
accept_invite_submit: 'Create my account',
|
||||
accept_invite_missing_token: 'Invalid link: missing invite token.',
|
||||
forgot_password_title: 'Recover password',
|
||||
forgot_password_submit: 'Send instructions',
|
||||
forgot_password_sent: 'If the email exists, we sent instructions to recover your password.',
|
||||
@@ -272,6 +284,7 @@ export const messages = {
|
||||
common_view: 'View',
|
||||
common_yes: 'Yes',
|
||||
common_no: 'No',
|
||||
common_or: 'or',
|
||||
common_loading: 'Loading...',
|
||||
common_empty: 'No data to show',
|
||||
common_confirm_delete_title: 'Confirm delete',
|
||||
@@ -327,6 +340,9 @@ export const messages = {
|
||||
project_role_admin: 'Administrator',
|
||||
project_make_admin: 'Make administrator',
|
||||
project_remove_admin: 'Remove administrator',
|
||||
project_invite_email: 'Invite by email',
|
||||
project_invite_email_hint: "If the email doesn't belong to anyone yet, they get a link to finish signing up and join the project.",
|
||||
project_invite_sent: 'Invite sent by email',
|
||||
project_created_at: 'Created',
|
||||
project_deleted_at: 'Deleted',
|
||||
project_delete_confirm_msg: 'Are you sure you want to delete this project? It will move to the trash and disappear from the list; a superadmin can restore it or delete it permanently.',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,3 +5,5 @@ export const verifyEmail = (token) => api.get(`/auth/verify-email?token=${encode
|
||||
export const forgotPassword = (email) => api.post('/auth/forgot-password', { email });
|
||||
export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password });
|
||||
export const changePassword = (currentPassword, newPassword) => api.put('/auth/change-password', { currentPassword, newPassword });
|
||||
export const getInviteInfo = (token) => api.get(`/auth/invite?token=${encodeURIComponent(token)}`);
|
||||
export const acceptInvite = (token, name, password) => api.post('/auth/accept-invite', { token, name, password });
|
||||
|
||||
@@ -10,7 +10,7 @@ export const updateProject = (id, data) => api.put(`/projects/${id}`, data);
|
||||
export const deleteProject = (id) => api.delete(`/projects/${id}`);
|
||||
export const restoreProject = (id) => api.post(`/projects/${id}/restore`);
|
||||
export const permanentlyDeleteProject = (id) => api.delete(`/projects/${id}/permanent`);
|
||||
export const addProjectMember = (id, userId, roleInProject) => api.post(`/projects/${id}/members`, { userId, roleInProject });
|
||||
export const addProjectMember = (id, { userId, email }, roleInProject) => api.post(`/projects/${id}/members`, { userId, email, roleInProject });
|
||||
export const setProjectMemberAdmin = (id, userId, admin) => api.put(`/projects/${id}/members/${userId}/role`, { admin });
|
||||
export const removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`);
|
||||
export const getGantt = (id) => api.get(`/projects/${id}/gantt`);
|
||||
|
||||
Reference in New Issue
Block a user