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
+13 -4
View File
@@ -30,6 +30,12 @@
`{ bootstrap: false }` y se envia (o loguea, sin SMTP) un correo de verificación.
- `GET /api/auth/verify-email?token=...` — público. Pasa el usuario a `pending_approval` y notifica
in-app a los `admin`/`superadmin` activos.
- `GET /api/auth/invite?token=...` — público. `{ email }` de la invitación (para prellenar el
formulario), o `400` si es inválida/expiró.
- `POST /api/auth/accept-invite` — público. `{ token, name, password }` → completa la cuenta
creada por `POST /api/projects/:id/members` con `{ email }` (ver arriba): elige nombre y clave,
pasa a `active` directo (sin aprobación — quien invitó ya la vetó) y devuelve `{ token, user }`
con sesión iniciada, igual que el bootstrap del primer usuario.
- `POST /api/auth/forgot-password` — público. `{ email }` → respuesta **siempre genérica**.
- `POST /api/auth/reset-password` — público. `{ token, password }`.
- `PUT /api/auth/change-password` — autenticado. `{ currentPassword, newPassword }`, exige la
@@ -53,10 +59,13 @@
editar el proyecto. `POST /api/projects/:id/restore` y `DELETE /api/projects/:id/permanent`
(borrado físico, irreversible, arrastra actividades/comentarios/adjuntos) — ambos solo
superadmin; `permanent` exige que el proyecto ya esté en la papelera (400 si no).
- `POST /api/projects/:id/members` (`roleInProject` opcional: `'admin'` = administrador del
proyecto) · `PUT /api/projects/:id/members/:userId/role` (`{ admin: true|false }`, otorga o
quita el rol de administrador **de ese proyecto** — un proyecto puede tener uno o más
administradores, sin importar el rol global del usuario; nunca toca al `owner`) ·
- `POST /api/projects/:id/members` — `{ userId }` (usuario existente) o `{ email }` (si el correo
no pertenece a nadie todavía, crea una cuenta `invited` y le manda un correo de invitación para
que complete su registro; la fila de `project_members` ya queda creada de una vez). `roleInProject`
opcional: `'admin'` = administrador del proyecto. Responde `{ members, invited }`.
- `PUT /api/projects/:id/members/:userId/role` (`{ admin: true|false }`, otorga o quita el rol de
administrador **de ese proyecto** — un proyecto puede tener uno o más administradores, sin
importar el rol global del usuario; nunca toca al `owner`) ·
`DELETE /api/projects/:id/members/:userId`
### Actividades (`/api/activities`)
+9 -2
View File
@@ -59,10 +59,17 @@
editables directo por el cliente: actividad con subtareas = % de subtareas hechas; actividad sin
subtareas = manual (0-100); proyecto = promedio del avance de sus actividades.
- `users.password_hash` con bcrypt, nunca texto plano ni reversible.
- `users.status` (`pending_verification`/`pending_approval`/`active`) es el ciclo de vida del
autorregistro, independiente de `is_active` (que sigue siendo el interruptor manual del admin
- `users.status` (`pending_verification`/`pending_approval`/`active`/`invited`) es el ciclo de vida
de la cuenta, independiente de `is_active` (que sigue siendo el interruptor manual del admin
para desactivar a alguien ya activo). Un usuario `pending_approval` rechazado se **borra**
físicamente (no hay estado "rechazado" — se audita el email en `audit_log` antes de borrar).
- `invited`: cuenta placeholder creada al agregar por correo a alguien que aún no es usuario desde
Miembros de un proyecto (`name`=`email`, `password_hash` aleatorio inservible, reutiliza
`verification_token`/`verification_token_expires` — 7 días en vez de 24h). Al aceptar la
invitación (`POST /api/auth/accept-invite`) pasa directo a `active` **sin pasar por
`pending_approval`** — a diferencia del autorregistro público, aquí quien lo invitó ya lo vetó al
agregarlo a su proyecto. La fila de `project_members` se crea en el momento de invitar, no al
aceptar, así que en cuanto acepta ya aparece como miembro sin pasos adicionales.
- `system_settings.value` para `smtp_pass_enc` va **cifrado** (AES-256-GCM, `ENCRYPTION_KEY` del
`.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`).
- Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con
+11
View File
@@ -1,5 +1,16 @@
# Changelog - project.linkedcorp.net
## [1.7.0] - 2026-09-10
### Added
- Agregar miembro a un proyecto ahora acepta un **correo que todavía no es usuario del sistema**
(campo "Invitar por correo" en el popup de Miembros, alternativo a elegir uno existente). Si el
correo no pertenece a nadie, se crea una cuenta en estado `invited` y se le manda un correo con
un enlace de un solo uso (`/aceptar-invitacion?token=...`, vence en 7 días) para que complete su
propio registro (elige nombre y clave); la fila de `project_members` ya queda creada de una vez,
así que en cuanto acepta ya aparece como miembro sin pasos adicionales. A diferencia del
autorregistro público, no pasa por aprobación de un admin — quien lo agregó a su proyecto ya lo
vetó. Nuevos endpoints públicos `GET /api/auth/invite` y `POST /api/auth/accept-invite`.
## [1.6.1] - 2026-09-09
### Changed
- Nuevo ícono/logo de la app derivado del ícono original de LinkedCorp (círculo azul de marca
+1 -1
View File
@@ -1 +1 @@
1.6.1
1.7.0
@@ -0,0 +1,6 @@
-- Invitar por correo a alguien que aun no es usuario, al agregarlo como miembro de un proyecto.
-- Reutiliza verification_token/verification_token_expires (mismo mecanismo que verificar correo).
SET NAMES utf8mb4;
ALTER TABLE users
MODIFY COLUMN status ENUM('pending_verification','pending_approval','active','invited') NOT NULL DEFAULT 'active';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.6.1",
"version": "1.7.0",
"private": true,
"type": "module",
"main": "src/server.js",
+10
View File
@@ -27,6 +27,16 @@ export const getVerifyEmail = asyncHandler(async (req, res) => {
ok(res, result);
});
export const getInvite = asyncHandler(async (req, res) => {
ok(res, await authService.getInviteInfo(req.query.token));
});
export const postAcceptInvite = asyncHandler(async (req, res) => {
const { token, name, password } = req.body || {};
const result = await authService.acceptInvite(token, name, password);
ok(res, result, 201);
});
export const postForgotPassword = asyncHandler(async (req, res) => {
const { email } = req.body || {};
if (email) await authService.forgotPassword(email);
+3 -1
View File
@@ -3,7 +3,7 @@ import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import {
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
putChangePassword,
putChangePassword, getInvite, postAcceptInvite,
} from './controller.js';
const router = Router();
@@ -21,6 +21,8 @@ router.post('/register', loginLimiter, postRegister);
router.get('/verify-email', loginLimiter, getVerifyEmail);
router.post('/forgot-password', loginLimiter, postForgotPassword);
router.post('/reset-password', loginLimiter, postResetPassword);
router.get('/invite', loginLimiter, getInvite);
router.post('/accept-invite', loginLimiter, postAcceptInvite);
router.post('/logout', requireAuth, postLogout);
router.get('/me', requireAuth, getMe);
router.put('/change-password', requireAuth, loginLimiter, putChangePassword);
+52
View File
@@ -11,6 +11,7 @@ import { createNotification } from '../notifications/service.js';
const STATUS_MESSAGES = {
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
pending_approval: 'Tu cuenta esta pendiente de aprobacion por un administrador.',
invited: 'Tienes una invitacion pendiente. Revisa tu correo para completar tu registro.',
};
function newToken() {
@@ -130,6 +131,57 @@ export async function verifyEmail(token) {
return { email: user.email };
}
// Info publica minima para prellenar el formulario de "aceptar invitacion" (solo el correo).
export async function getInviteInfo(token) {
if (!token) throw new HttpError(400, 'Token invalido');
const [rows] = await pool.query(
`SELECT email FROM users WHERE verification_token = ? AND status = 'invited' AND verification_token_expires > NOW()`,
[token]
);
const user = rows[0];
if (!user) throw new HttpError(400, 'La invitacion es invalida o expiro');
return { email: user.email };
}
// Completa la cuenta creada por una invitacion a un proyecto (ver modules/users/service.js
// inviteUser): la persona elige su nombre y clave. A diferencia del autorregistro publico, no
// pasa por aprobacion de un admin — quien la invito ya la vetó al agregarla a su proyecto. Devuelve
// sesion iniciada de una vez, igual que el bootstrap del primer usuario.
export async function acceptInvite(token, name, password) {
if (!token || !name || !password || password.length < 8) {
throw new HttpError(400, 'Nombre, token y una clave de al menos 8 caracteres son obligatorios');
}
const [rows] = await pool.query(
`SELECT id FROM users WHERE verification_token = ? AND status = 'invited' AND verification_token_expires > NOW()`,
[token]
);
const user = rows[0];
if (!user) throw new HttpError(400, 'La invitacion es invalida o expiro');
const passwordHash = await bcrypt.hash(password, 12);
await pool.query(
`UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', email_verified_at = NOW(),
verification_token = NULL, verification_token_expires = NULL
WHERE id = ?`,
[name, passwordHash, user.id]
);
await logAudit({ userId: user.id, action: 'accept_invite', entityType: 'user', entityId: user.id });
const [full] = await pool.query(
`SELECT u.id, u.name, u.email, u.department_id, r.code AS role_code, r.name AS role_name
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
[user.id]
);
const jwtToken = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
return {
token: jwtToken,
user: {
id: full[0].id, name: full[0].name, email: full[0].email,
roleCode: full[0].role_code, roleName: full[0].role_name, departmentId: full[0].department_id,
},
};
}
export async function forgotPassword(email) {
const [rows] = await pool.query(
`SELECT id, name FROM users WHERE email = ? AND status = 'active' AND is_active = 1`,
+8 -2
View File
@@ -34,8 +34,14 @@ export const deleteProjectPermanent = asyncHandler(async (req, res) => {
});
export const postMember = asyncHandler(async (req, res) => {
const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser);
ok(res, members, 201);
const { userId, email, roleInProject } = req.body;
const result = await service.addMember(
Number(req.params.id),
{ userId: userId ? Number(userId) : null, email },
roleInProject,
req.currentUser,
);
ok(res, result, 201);
});
export const putMemberRole = asyncHandler(async (req, res) => {
+22 -4
View File
@@ -1,4 +1,5 @@
import * as repo from './repository.js';
import * as usersService from '../users/service.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { ROLES } from '../../middleware/auth.js';
@@ -94,13 +95,30 @@ export async function recalcProjectProgress(projectId) {
await repo.updateProgress(projectId, Math.round(avg * 100) / 100);
}
export async function addMember(projectId, userId, roleInProject, currentUser) {
// Agrega por id de usuario existente O por correo. Si el correo no pertenece a nadie todavia,
// crea una cuenta 'invited' y le manda un correo para que complete su propio registro (nombre y
// clave) — la fila de project_members ya queda creada de una vez, asi que en cuanto acepte la
// invitacion ya aparece como miembro sin pasos adicionales.
export async function addMember(projectId, { userId, email }, roleInProject, currentUser) {
const project = await repo.findById(projectId);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
const members = await repo.addMember(projectId, userId, roleInProject);
await logAudit({ userId: currentUser.id, action: 'add_member', entityType: 'project', entityId: projectId, detail: { userId } });
return members;
let targetUserId = userId;
let invited = false;
if (!targetUserId) {
if (!email) throw new HttpError(400, 'Indica un usuario existente o un correo');
const result = await usersService.inviteUser(email, currentUser.id);
targetUserId = result.userId;
invited = result.invited;
}
const members = await repo.addMember(projectId, targetUserId, roleInProject);
await logAudit({
userId: currentUser.id, action: 'add_member', entityType: 'project', entityId: projectId,
detail: { userId: targetUserId, invited },
});
return { members, invited };
}
export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
+16
View File
@@ -26,6 +26,22 @@ export async function findByEmail(email) {
return rows[0] || null;
}
export async function findRoleIdByCode(code) {
const [rows] = await pool.query('SELECT id FROM roles WHERE code = ?', [code]);
return rows[0]?.id || null;
}
// Cuenta placeholder para alguien invitado por correo (aun no es usuario del sistema): sin nombre
// ni clave reales todavia (se completan al aceptar la invitacion), status 'invited' bloquea login.
export async function createInvited({ email, roleId, passwordHash, token }) {
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, verification_token, verification_token_expires)
VALUES (?, ?, ?, ?, 0, 'invited', ?, DATE_ADD(NOW(), INTERVAL 7 DAY))`,
[email, email, passwordHash, roleId, token]
);
return result.insertId;
}
export async function create({ name, email, passwordHash, roleId, departmentId, position }) {
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
+30
View File
@@ -1,11 +1,41 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import * as repo from './repository.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { env } from '../../config/env.js';
export const listUsers = () => repo.findAll();
export const listRoles = () => repo.listRoles();
// Invita por correo a alguien que todavia no es usuario: crea la cuenta en estado 'invited' (sin
// nombre/clave reales, solo un placeholder) y le manda un enlace de un solo uso para que complete
// su propio registro (elige nombre y clave). Si el correo ya pertenece a un usuario existente (en
// cualquier estado), no crea nada nuevo — quien llama simplemente lo agrega como miembro directo.
export async function inviteUser(email, actorId) {
const existing = await repo.findByEmail(email);
if (existing) return { userId: existing.id, invited: false };
const roleId = await repo.findRoleIdByCode('collaborator');
const passwordHash = await bcrypt.hash(crypto.randomBytes(24).toString('hex'), 12);
const token = crypto.randomBytes(32).toString('hex');
const userId = await repo.createInvited({ email, roleId, passwordHash, token });
await logAudit({ userId: actorId, action: 'invite', entityType: 'user', entityId: userId, detail: { email } });
const inviteUrl = `${env.corsOrigin}/aceptar-invitacion?token=${token}`;
await sendMail({
to: email,
subject: 'Te invitaron a project.linkedcorp.net',
html: `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
<p><a href="${inviteUrl}">${inviteUrl}</a></p><p>Este enlace vence en 7 dias.</p>`,
});
return { userId, invited: true };
}
export async function createUser(payload, actorId) {
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "1.6.1",
"version": "1.7.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -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 />} />
+16
View File
@@ -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>
+2
View File
@@ -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 });
+1 -1
View File
@@ -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`);