You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 6f8a8cd
Browse filesBrowse the repository at this point in the historyBrowse files
feat(operadores): alta de usuarios piloto con contraseña temporal forzada
Onboarding del equipo piloto Ancud/Quellón con cuentas por rol:
- api/operators: toda contraseña definida por gestión es temporal
(must_change_password=true), rate limit por IP y auditoría
operator_created/password_reset en auth_events
- api/password (nuevo): cambio de contraseña propio con sesión; verifica
la temporal con scrypt, limpia el flag y audita password_changed
- migración 058 (aplicada en prod): operators.must_change_password y
extensión del check de auth_events a eventos de ciclo de vida
- App gate: sesión con must_change_password renderiza AccountSetup
(cambio forzado) y después una bienvenida mínima con los caminos
primarios del rol reutilizando roleExperience (sin copy nueva)
- /operadores: badge 'Cambio de contraseña pendiente' y hint de temporal;
SecurityAudit etiqueta los nuevos eventos
- contrato audit-smoke extendido y spec Playwright del flujo completo
Copy file name to clipboardExpand all lines: README.md
+7Lines changed: 7 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -149,6 +149,13 @@ Pescamar remains a named implementation inside the broader Seafood Intelligence
149
149
150
150
The rollout and productization plan is maintained in [`ROADMAP.md`](./ROADMAP.md). Pilot acceptance remains governed by [`PILOT_ACCEPTANCE.md`](./PILOT_ACCEPTANCE.md).
151
151
152
+
### Alta de usuarios piloto
153
+
154
+
1. Un administrador entra a **Operadores** (`/operadores`) y crea la identidad con nombre, correo, rol y plantas (Ancud/Quellón) asignando una **contraseña temporal**; el servidor la marca siempre como temporal (`must_change_password`) y registra el alta en `auth_events`.
155
+
2. Se entrega el correo y la contraseña temporal al usuario por un canal privado (no hay envío de correo automático ni tokens de invitación).
156
+
3. Al primer login la aplicación exige definir contraseña propia antes de mostrar nada (`/api/password` verifica la temporal, limpia el flag y audita `password_changed`); después muestra una bienvenida mínima con los caminos primarios del rol.
157
+
4. Roles del piloto: operador de planta → `operations` (o `quality` para calidad), responsable comercial/administrativo → `finance`, gestión por excepción → `admin` (o `viewer` para sólo lectura). Altas y cambios de contraseña quedan en **Auditoría de acceso** (`/modulos`).
constrows=awaitgetSql()`select id,full_name,email,role,password_hash,plant_ids,must_change_password from operators where lower(email)=${email} and active=true limit 1`;
returnresponse.status(configuration||migration?503:500).json({ok:false,error:configuration?"Base de datos no conectada":migration?"Faltan migraciones de autenticación":"No fue posible autenticar"});
@@ -18,12 +20,13 @@ export default async function handler(request:Request,response:Response){
18
20
19
21
if(request.method==="GET"){
20
22
constrows=isAdmin
21
-
? awaitgetSql()`select id,full_name,email,role,active,plant_ids,created_at,(password_hash is not null) as credentials_ready from operators order by active desc,full_name asc`
22
-
: awaitgetSql()`select id,full_name,email,role,active,plant_ids,created_at,(password_hash is not null) as credentials_ready from operators where role in ('operations','quality','viewer') and plant_ids && ${actor.plantIds}::text[] order by active desc,full_name asc`;
23
+
? awaitgetSql()`select id,full_name,email,role,active,plant_ids,must_change_password,created_at,(password_hash is not null) as credentials_ready from operators order by active desc,full_name asc`
24
+
: awaitgetSql()`select id,full_name,email,role,active,plant_ids,must_change_password,created_at,(password_hash is not null) as credentials_ready from operators where role in ('operations','quality','viewer') and plant_ids && ${actor.plantIds}::text[] order by active desc,full_name asc`;
@@ -41,16 +44,20 @@ export default async function handler(request:Request,response:Response){
41
44
}
42
45
if(name.length<2||name.length>120||!emailPattern.test(email)||email.length>254||!roles.has(role)||password.length<12||password.length>256)returnresponse.status(400).json({ok:false,error:"Nombre, correo, rol y contraseña de 12 a 256 caracteres son obligatorios"});
43
46
constpasswordHash=hashPassword(password);
44
-
constrows=awaitgetSql()`insert into operators (full_name,email,role,password_hash,plant_ids) values (${name},${email},${role},${passwordHash},${plantIds}) on conflict (lower(email)) do update set full_name=excluded.full_name,role=excluded.role,password_hash=excluded.password_hash,plant_ids=excluded.plant_ids,active=true,updated_at=now() returning id,full_name,email,role,active,plant_ids,created_at,true as credentials_ready`;
// Contraseñas definidas por gestión son siempre temporales: el servidor
48
+
// exige el cambio al primer login (operators.must_change_password).
49
+
constrows=awaitgetSql()`insert into operators (full_name,email,role,password_hash,plant_ids,must_change_password) values (${name},${email},${role},${passwordHash},${plantIds},true) on conflict (lower(email)) do update set full_name=excluded.full_name,role=excluded.role,password_hash=excluded.password_hash,plant_ids=excluded.plant_ids,must_change_password=excluded.must_change_password,active=true,updated_at=now() returning id,full_name,email,role,active,plant_ids,created_at,(password_hash is not null) as credentials_ready,(xmax=0) as inserted`;
returnresponse.status(configuration||migration?503:500).json({ok:false,error:configuration?"Base de datos no conectada":migration?"Falta aplicar la migración 003_operator_auth.sql":"No fue posible administrar operadores"});
if(!verifyPassword(currentPassword,stored))returnresponse.status(403).json({ok:false,error:"La contraseña actual no es válida"});
35
+
awaitgetSql()`update operators set password_hash=${hashPassword(newPassword)},must_change_password=false,updated_at=now() where id=${operator.id}::uuid`;
returnresponse.status(configuration||migration?503:500).json({ok:false,error:configuration?"Base de datos no conectada":migration?"Faltan migraciones de credenciales":"No fue posible cambiar la contraseña"});
0 commit comments