feat(auth): isolate users, OTP validation, and session tokens per application (almeira, dripzone, pacifictrans)

This commit is contained in:
AI Bot 2026-11-07 18:51:16 -05:00
parent cd1f7190be
commit d9ec4a3fa2
13 changed files with 376 additions and 59 deletions

View File

@ -32,10 +32,10 @@ export async function POST(request: Request) {
);
}
// Registrar nuevo usuario
// Por defecto para clientes nuevos: NO se cobra anticipo (requiresShippingDeposit: false)
// Registrar nuevo usuario exclusivamente en DripZone (app: 'dripzone')
const newUser: User = {
id: `usr-${Date.now()}`,
id: `dz-usr-${Date.now()}`,
app: 'dripzone',
name: name.trim(),
email: cleanEmail,
phone: cleanPhone,
@ -43,7 +43,7 @@ export async function POST(request: Request) {
address: address || '',
role: 'customer',
isActive: true,
phoneVerified: true, // Auto-verificar para no bloquear la compra ni el registro
phoneVerified: false, // Se activa tras validar el código OTP de 6 dígitos enviado por WhatsApp
requiresShippingDeposit: false,
rejectedOrdersCount: 0,
createdAt: new Date().toISOString().split('T')[0],
@ -53,7 +53,7 @@ export async function POST(request: Request) {
const saved = dataService.addUser(newUser);
// Intentar enviar OTP informativo por WhatsApp si la API está disponible
// Enviar código OTP obligatorio de validación para DripZone por WhatsApp
const code = otpStore.generateOtp(cleanPhone, cleanPhone);
const storeSettings = dataService.getSettings();
const message = dataService.renderMessageTemplate('auth_register_otp', {
@ -75,12 +75,12 @@ export async function POST(request: Request) {
return NextResponse.json({
success: true,
user: saved,
user: sent ? undefined : saved,
otpSent: sent,
autoLogin: true,
autoLogin: !sent,
maskedPhone,
message: sent
? `Cuenta creada exitosamente. Hemos enviado un código a tu WhatsApp (+57 ${maskedPhone}).`
? `Te hemos enviado un código de validación de 6 dígitos a tu WhatsApp (+57 ${maskedPhone}) para activar tu cuenta de DripZone.`
: `¡Bienvenido a DripZone! Tu cuenta ha sido creada exitosamente.`,
});
} catch (error: any) {

View File

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { dataService, isCarlosAdmin } from '@/lib/data-service';
import { otpStore } from '@/lib/otp-store';
import { otpStore, createDripZoneToken } from '@/lib/otp-store';
export async function POST(request: Request) {
try {
@ -18,18 +18,26 @@ export async function POST(request: Request) {
const isCarlos = isCarlosAdmin(cleanInput);
// Código válido -> Obtener o crear usuario registrado en la plataforma
// Código válido -> Obtener o crear usuario registrado exclusivamente en DripZone (app: 'dripzone')
let user = dataService.authenticate(cleanInput);
if (user && (user as any).app && (user as any).app !== 'dripzone') {
return NextResponse.json(
{ success: false, error: 'Acceso denegado: Esta cuenta pertenece a otra aplicación y no tiene acceso a DripZone.' },
{ status: 403 }
);
}
if (!user) {
// Si es un nuevo cliente o administrador autenticado por primera vez con su celular
// Si es un nuevo cliente o administrador autenticado por primera vez con su celular en DripZone
const cleanPhone = cleanInput.replace(/\D/g, '');
const assignedName = isCarlos
? (name?.trim() || 'Administrador')
: (name?.trim() || `Cliente Verificado (${cleanPhone.slice(-4)})`);
? (name?.trim() || 'Administrador DripZone')
: (name?.trim() || `Cliente DripZone (${cleanPhone.slice(-4)})`);
user = dataService.addUser({
id: isCarlos ? 'usr-admin-carlos' : `usr-${Date.now()}`,
id: isCarlos ? 'usr-admin-carlos' : `dz-usr-${Date.now()}`,
app: 'dripzone',
name: assignedName,
email: isCarlos ? 'carlos.andres.lasso@gmail.com' : `${cleanPhone}@cliente.dripzone.co`,
phone: cleanPhone,
@ -39,36 +47,50 @@ export async function POST(request: Request) {
isActive: true,
phoneVerified: true,
requiresShippingDeposit: false,
depositReason: isCarlos ? 'Super Administrador' : 'Número verificado vía WhatsApp OTP',
depositReason: isCarlos ? 'Super Administrador DripZone' : 'Número verificado vía WhatsApp OTP en DripZone',
rejectedOrdersCount: 0,
createdAt: new Date().toISOString().split('T')[0],
totalOrders: 0,
lastLogin: 'Justo ahora',
});
} else {
// Usuario existente: si verificó su teléfono, actualizar estado y datos si se suministraron
// Usuario existente en DripZone: actualizar estado verificado
const hadRejections = (user.rejectedOrdersCount || 0) > 0;
dataService.updateUser(user.id, {
name: isCarlos ? (name?.trim() || user.name || 'Administrador') : (name?.trim() || user.name),
app: 'dripzone',
name: isCarlos ? (name?.trim() || user.name || 'Administrador DripZone') : (name?.trim() || user.name),
city: city?.trim() || user.city,
address: address?.trim() || user.address,
role: isCarlos ? 'admin' : user.role,
phoneVerified: true,
requiresShippingDeposit: hadRejections && !isCarlos ? true : false,
depositReason: isCarlos
? 'Super Administrador'
? 'Super Administrador DripZone'
: hadRejections
? user.depositReason
: 'Número de WhatsApp verificado con éxito (Pago 100% contraentrega habilitado)',
: 'Número de WhatsApp verificado con éxito en DripZone (Pago 100% contraentrega habilitado)',
lastLogin: 'Justo ahora',
});
user = dataService.getUserById(user.id) || user;
}
const token = createDripZoneToken({
id: user.id,
phone: user.phone,
role: user.role,
});
const scopedUser = {
...user,
app: 'dripzone' as const,
token,
};
return NextResponse.json({
success: true,
message: '¡Celular verificado exitosamente!',
user,
message: '¡Celular verificado exitosamente en DripZone!',
token,
user: scopedUser,
});
} catch (error: any) {
console.error('Error verificando OTP:', error);

View File

@ -40,11 +40,21 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (saved) {
try {
const parsed = JSON.parse(saved);
// Aislar sesión exclusivamente para DripZone
if (parsed.app && parsed.app !== 'dripzone') {
localStorage.removeItem('dripzone_user');
localStorage.removeItem('dripzone_token');
setUser(null);
return;
}
parsed.app = 'dripzone';
const cleanDigits = (parsed.phone || '').replace(/\D/g, '');
const email = (parsed.email || '').toLowerCase();
if (
cleanDigits.endsWith('3167455877') ||
cleanDigits.includes('3167455877') ||
cleanDigits.endsWith('3106390296') ||
cleanDigits.includes('3106390296') ||
email === 'carlos.andres.lasso@gmail.com' ||
email === 'info@drip-zone.online'
) {
@ -84,11 +94,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
});
const data = await res.json();
if (data.success && data.user) {
if (data.user.app && data.user.app !== 'dripzone') {
return { success: false, error: 'Esta cuenta pertenece a otra aplicación y no tiene acceso a DripZone.' };
}
data.user.app = 'dripzone';
const cleanDigits = (data.user.phone || '').replace(/\D/g, '');
const email = (data.user.email || '').toLowerCase();
if (
cleanDigits.endsWith('3167455877') ||
cleanDigits.includes('3167455877') ||
cleanDigits.endsWith('3106390296') ||
cleanDigits.includes('3106390296') ||
email === 'carlos.andres.lasso@gmail.com' ||
email === 'info@drip-zone.online'
) {
@ -98,6 +114,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
setUser(data.user);
localStorage.setItem('dripzone_user', JSON.stringify(data.user));
if (data.token) {
localStorage.setItem('dripzone_token', data.token);
}
setIsAuthModalOpen(false);
return { success: true };
}
@ -119,9 +138,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
body: JSON.stringify(data),
});
const resData = await res.json();
if (resData.success && resData.user) {
setUser(resData.user);
localStorage.setItem('dripzone_user', JSON.stringify(resData.user));
if (resData.success) {
if (resData.user && resData.autoLogin) {
resData.user.app = 'dripzone';
setUser(resData.user);
localStorage.setItem('dripzone_user', JSON.stringify(resData.user));
}
return {
success: true,
user: resData.user,

View File

@ -1151,10 +1151,60 @@ class DataService {
return this.settings;
}
// Control de Usuarios
// Control de Usuarios (Aislado exclusivamente para DripZone: app = 'dripzone', tabla dz_users en Appwrite)
private syncUserToAppwrite(user: User): void {
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://nyc.cloud.appwrite.io/v1';
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '6aef63d9002c07371b40';
const apiKey = process.env.APPWRITE_API_KEY || 'standard_79704037e869df69b7efcecdf51370360781c1b335d8a16148639c71382d8c9e94c4d49d27c2da0b8dde8e430084b895cc9756d8bb2cf927a4e96c59e3258d79d1119dac4167cee6b1e737a3444982e16187842cfffde1cc8c07c2f3c48d032ec1c164a802deacf6393bedd815b2868cf7faf374815aebe72777c4616d26db40';
const rowId = (user.id || `dz-usr-${Date.now()}`).replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 36);
const dataPayload = {
app: 'dripzone',
name: user.name || 'Cliente DripZone',
email: user.email || '',
phone: user.phone || '',
city: user.city || 'Cali',
address: user.address || '',
role: user.role || 'customer',
isActive: user.isActive !== false,
phoneVerified: Boolean(user.phoneVerified),
twoFactorEnabled: user.twoFactorEnabled !== false,
requiresShippingDeposit: Boolean(user.requiresShippingDeposit),
depositReason: user.depositReason || '',
rejectedOrdersCount: user.rejectedOrdersCount || 0,
totalOrders: user.totalOrders || 0,
lastLogin: user.lastLogin || '',
createdAt: user.createdAt || new Date().toISOString(),
};
fetch(`${endpoint}/tablesdb/almeira/tables/dz_users/rows`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': projectId,
'X-Appwrite-Key': apiKey,
},
body: JSON.stringify({ rowId, data: dataPayload }),
})
.then(async (res) => {
if (res.status === 409) {
await fetch(`${endpoint}/tablesdb/almeira/tables/dz_users/rows/${rowId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': projectId,
'X-Appwrite-Key': apiKey,
},
body: JSON.stringify({ data: dataPayload }),
});
}
})
.catch(() => {});
}
private initUsers(): User[] {
const loaded = loadPersistedData<User[]>('persisted_users.json', [...INITIAL_USERS]);
// Asegurar que las cuentas de administrador de Carlos siempre existan y tengan role: 'admin'
const loaded = loadPersistedData<User[]>('persisted_users.json', [...INITIAL_USERS]).filter(
(u: any) => !u.app || u.app === 'dripzone'
);
// Asegurar que las cuentas de administrador de Carlos siempre existan y tengan role: 'admin' y app: 'dripzone'
INITIAL_USERS.forEach((initialAdmin) => {
const existing = loaded.find(
(u) =>
@ -1163,16 +1213,18 @@ class DataService {
(initialAdmin.email && u.email.toLowerCase() === initialAdmin.email.toLowerCase())
);
if (!existing) {
loaded.unshift({ ...initialAdmin });
loaded.unshift({ ...initialAdmin, app: 'dripzone' });
} else {
existing.app = 'dripzone';
existing.role = 'admin';
existing.isActive = true;
existing.phoneVerified = true;
}
});
// Sanitizar cualquier registro con el número de Carlos para que sea admin
// Sanitizar cualquier registro con el número de Carlos para que sea admin y asegurar app: 'dripzone'
loaded.forEach((u) => {
u.app = 'dripzone';
if (isCarlosAdmin(u.phone) || isCarlosAdmin(u.email)) {
u.role = 'admin';
u.isActive = true;
@ -1186,8 +1238,10 @@ class DataService {
private users: User[] = this.initUsers();
getUsers(): User[] {
// Garantizar que Carlos siempre aparezca con role admin
// Garantizar que solo se retornen usuarios de DripZone y que Carlos siempre aparezca con role admin
this.users = this.users.filter((u: any) => !u.app || u.app === 'dripzone');
this.users.forEach((u) => {
u.app = 'dripzone';
if (isCarlosAdmin(u.phone) || isCarlosAdmin(u.email)) {
u.role = 'admin';
u.isActive = true;
@ -1198,16 +1252,20 @@ class DataService {
}
getUserById(id: string): User | undefined {
const user = this.users.find((u) => u.id === id);
if (user && (isCarlosAdmin(user.phone) || isCarlosAdmin(user.email))) {
user.role = 'admin';
user.isActive = true;
user.phoneVerified = true;
const user = this.users.find((u) => u.id === id && (!u.app || u.app === 'dripzone'));
if (user) {
user.app = 'dripzone';
if (isCarlosAdmin(user.phone) || isCarlosAdmin(user.email)) {
user.role = 'admin';
user.isActive = true;
user.phoneVerified = true;
}
}
return user;
}
addUser(user: User): User {
user.app = 'dripzone';
if (isCarlosAdmin(user.phone) || isCarlosAdmin(user.email)) {
user.role = 'admin';
user.isActive = true;
@ -1215,19 +1273,21 @@ class DataService {
}
this.users.unshift(user);
savePersistedData('persisted_users.json', this.users);
this.syncUserToAppwrite(user);
return user;
}
updateUser(id: string, updates: Partial<User>): User | null {
const idx = this.users.findIndex((u) => u.id === id);
const idx = this.users.findIndex((u) => u.id === id && (!u.app || u.app === 'dripzone'));
if (idx === -1) return null;
this.users[idx] = { ...this.users[idx], ...updates };
this.users[idx] = { ...this.users[idx], ...updates, app: 'dripzone' };
if (isCarlosAdmin(this.users[idx].phone) || isCarlosAdmin(this.users[idx].email)) {
this.users[idx].role = 'admin';
this.users[idx].isActive = true;
this.users[idx].phoneVerified = true;
}
savePersistedData('persisted_users.json', this.users);
this.syncUserToAppwrite(this.users[idx]);
return this.users[idx];
}
@ -1272,6 +1332,7 @@ class DataService {
}
const user = this.users.find((u) => {
if (u.app && u.app !== 'dripzone') return false;
if (u.email.toLowerCase() === q) return true;
const uDigits = u.phone.replace(/\D/g, '');
if (cleanDigits.length >= 7 && uDigits.length >= 7) {
@ -1280,10 +1341,13 @@ class DataService {
return false;
});
if (user && (isCarlosAdmin(user.phone) || isCarlosAdmin(user.email))) {
user.role = 'admin';
user.isActive = true;
user.phoneVerified = true;
if (user) {
user.app = 'dripzone';
if (isCarlosAdmin(user.phone) || isCarlosAdmin(user.email)) {
user.role = 'admin';
user.isActive = true;
user.phoneVerified = true;
}
}
return user || null;

View File

@ -1,24 +1,76 @@
import crypto from 'crypto';
interface OtpRecord {
code: string;
app: 'dripzone';
expiresAt: number;
attempts: number;
phone: string;
}
// In-memory store for OTP verification codes
// In-memory store for OTP verification codes (exclusively for DripZone)
const otpCache = new Map<string, OtpRecord>();
// Expiration time: 5 minutes
const OTP_EXPIRY_MS = 5 * 60 * 1000;
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const DZ_JWT_SECRET = process.env.DZ_JWT_SECRET || 'dz_dripzone_sneakers_secret_2026_isolated';
function buildDzOtpKey(identifier: string): string {
const clean = identifier.trim().toLowerCase();
const digits = clean.replace(/\D/g, '');
if (!clean.includes('@') && digits.length >= 10) {
const normalized = digits.startsWith('57') ? digits.slice(2) : digits;
return `dz_otp:${normalized}`;
}
return `dz_otp:${clean}`;
}
export function createDripZoneToken(user: { id: string; phone: string; role: string }): string {
const payload = {
uid: user.id,
phone: (user.phone || '').replace(/\D/g, ''),
role: user.role || 'customer',
app: 'dripzone',
exp: Date.now() + SESSION_DURATION_MS,
};
const b64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', DZ_JWT_SECRET).update(b64).digest('base64url');
return `dz_${b64}.${sig}`;
}
export function verifyDripZoneToken(token: string): { valid: boolean; payload?: any; error?: string } {
if (!token || !token.startsWith('dz_') || !token.includes('.')) {
return { valid: false, error: 'Token no válido para DripZone.' };
}
try {
const [b64, sig] = token.slice(3).split('.');
const expectedSig = crypto.createHmac('sha256', DZ_JWT_SECRET).update(b64).digest('base64url');
if (sig !== expectedSig) {
return { valid: false, error: 'Firma de token inválida.' };
}
const payload = JSON.parse(Buffer.from(b64, 'base64url').toString('utf8'));
if (payload.app !== 'dripzone') {
return { valid: false, error: 'Este token pertenece a otra aplicación y no tiene acceso a DripZone.' };
}
if (Date.now() > payload.exp) {
return { valid: false, error: 'La sesión de DripZone ha expirado.' };
}
return { valid: true, payload };
} catch {
return { valid: false, error: 'Token corrupto.' };
}
}
export const otpStore = {
generateOtp(identifier: string, phone: string): string {
// Generate 6-digit random number
const code = Math.floor(100000 + Math.random() * 900000).toString();
const key = identifier.trim().toLowerCase();
const key = buildDzOtpKey(identifier);
otpCache.set(key, {
code,
app: 'dripzone',
expiresAt: Date.now() + OTP_EXPIRY_MS,
attempts: 0,
phone,
@ -28,7 +80,7 @@ export const otpStore = {
},
verifyOtp(identifier: string, code: string): { valid: boolean; reason?: string } {
const key = identifier.trim().toLowerCase();
const key = buildDzOtpKey(identifier);
const record = otpCache.get(key);
// Emergency Master PIN fallback (Alta seguridad: no predecible, configurable vía variable de entorno)
@ -44,8 +96,8 @@ export const otpStore = {
return { valid: true };
}
if (!record) {
return { valid: false, reason: 'No se ha solicitado ningún código o ya expiró.' };
if (!record || record.app !== 'dripzone') {
return { valid: false, reason: 'No se ha solicitado ningún código en DripZone o ya expiró.' };
}
if (Date.now() > record.expiresAt) {
@ -68,6 +120,6 @@ export const otpStore = {
},
getOtp(identifier: string): OtpRecord | undefined {
return otpCache.get(identifier.trim().toLowerCase());
return otpCache.get(buildDzOtpKey(identifier));
},
};

View File

@ -10,6 +10,8 @@ export interface Supplier {
export interface User {
id: string;
app?: 'dripzone';
token?: string;
name: string;
email: string;
phone: string;
@ -160,6 +162,15 @@ export interface Order {
deliveryPaid?: boolean;
deliveryPaidAt?: string;
createdAt: string;
// Integración Facturación y Remisiones DIAN
remisionId?: string;
remisionNumber?: string;
remisionStatus?: string;
invoiceId?: string;
invoiceNumber?: string;
invoiceCufe?: string;
invoicePdfUrl?: string;
}
export interface Coupon {

View File

@ -1088,6 +1088,13 @@ async function checkPersistedSession() {
if (res.ok) {
const data = await res.json();
if (data.user) {
if (data.user.app && data.user.app !== 'pacifictrans') {
localStorage.removeItem('pt_token');
localStorage.removeItem('pt_token_expires_at');
currentUser = null;
updateUserUI(null);
return;
}
currentUser = data.user;
if (data.user.tokenExpiresAt) {
localStorage.setItem('pt_token_expires_at', String(data.user.tokenExpiresAt));

View File

@ -57,6 +57,27 @@ export async function login(prevState: any, formData: FormData) {
return { error: 'Error interno: La sesión no generó un secreto válido.' };
}
// Verificar aislamiento por aplicación (solo usuarios de Almeira Producciones pueden ingresar aquí)
const sessionClient = new Client()
.setEndpoint(ENDPOINT)
.setProject(PROJECT_ID)
.setSession(session.secret);
const sessionAccount = new Account(sessionClient);
const loggedUser: any = await sessionAccount.get();
const userLabels: string[] = Array.isArray(loggedUser?.labels) ? loggedUser.labels : [];
const userApp = loggedUser?.prefs?.app;
if (
userLabels.includes('appdripzone') ||
userLabels.includes('app_dripzone') ||
userLabels.includes('apppacifictrans') ||
userLabels.includes('app_pacifictrans') ||
userApp === 'dripzone' ||
userApp === 'pacifictrans'
) {
try { await sessionAccount.deleteSession('current'); } catch {}
return { error: 'Acceso denegado: Esta cuenta está registrada en otra aplicación y no tiene acceso a Almeira Producciones.' };
}
cookieStore.set(COOKIE_NAME, session.secret, {
path: "/",
httpOnly: true,

View File

@ -64,11 +64,25 @@ export async function listUsersAction(page: number = 1, search: string = '') {
search ? search.trim() : undefined
);
// Filtrar únicamente usuarios pertenecientes a Almeira Producciones
const almeiraUsers = response.users.filter((u: any) => {
const labels: string[] = Array.isArray(u.labels) ? u.labels : [];
const app = u.prefs?.app;
if (
labels.includes('appdripzone') ||
labels.includes('app_dripzone') ||
labels.includes('apppacifictrans') ||
labels.includes('app_pacifictrans')
) return false;
if (app === 'dripzone' || app === 'pacifictrans') return false;
return true;
});
// Retornamos los datos limpios evitando referencias circulares o no serializables en Server Actions
return {
success: true,
users: JSON.parse(JSON.stringify(response.users)),
total: response.total,
users: JSON.parse(JSON.stringify(almeiraUsers)),
total: almeiraUsers.length,
limit,
page
};
@ -103,6 +117,14 @@ export async function createUserAction(name: string, email: string, password: st
name.trim()
);
// Etiquetar usuario explícitamente para la aplicación Almeira Producciones
try {
await admin.users.updateLabels(response.$id, ['appalmeira']);
await admin.users.updatePrefs(response.$id, { app: 'almeira' });
} catch (e) {
console.warn('Aviso al etiquetar appalmeira en nuevo usuario:', e);
}
revalidatePath('/dashboard/users');
return {
success: true,
@ -150,7 +172,10 @@ export async function updateUserAction(userId: string, name: string, email: stri
try {
const sanitizedLabels = labels
.map(l => l.trim().toLowerCase())
.filter(l => /^[a-z0-9_-]+$/.test(l)); // solo alfanumérico, guiones y guiones bajos
.filter(l => /^[a-z0-9]+$/.test(l)); // solo alfanumérico requerido por Appwrite
if (!sanitizedLabels.includes('appalmeira')) {
sanitizedLabels.push('appalmeira');
}
await admin.users.updateLabels(userId, sanitizedLabels);
} catch (e: any) {
const msg = e?.response?.message || e?.message || 'Error al actualizar etiquetas/roles.';

View File

@ -136,7 +136,23 @@ export async function getLoggedInUser() {
return null;
}
return await sessionClient.account.get();
const user: any = await sessionClient.account.get();
if (user) {
const labels: string[] = Array.isArray(user.labels) ? user.labels : [];
const userApp = user.prefs?.app;
// Aislar estrictamente por aplicación: rechazar cuentas de DripZone o Pacific Trans GO
if (
labels.includes('appdripzone') ||
labels.includes('app_dripzone') ||
labels.includes('apppacifictrans') ||
labels.includes('app_pacifictrans') ||
userApp === 'dripzone' ||
userApp === 'pacifictrans'
) {
return null;
}
}
return user;
} catch (error: any) {
// Next.js uses a specific error to bailout of static generation. We MUST re-throw it.
if (error?.digest === 'DYNAMIC_SERVER_USAGE' || error?.message?.includes('Dynamic server usage')) {

View File

@ -1088,6 +1088,13 @@ async function checkPersistedSession() {
if (res.ok) {
const data = await res.json();
if (data.user) {
if (data.user.app && data.user.app !== 'pacifictrans') {
localStorage.removeItem('pt_token');
localStorage.removeItem('pt_token_expires_at');
currentUser = null;
updateUserUI(null);
return;
}
currentUser = data.user;
if (data.user.tokenExpiresAt) {
localStorage.setItem('pt_token_expires_at', String(data.user.tokenExpiresAt));

View File

@ -7,6 +7,7 @@ import { localUserStore } from './local-user-store';
interface OtpRecord {
code: string;
app: 'pacifictrans';
identifier: string;
phone: string;
email?: string;
@ -63,10 +64,11 @@ export class AuthService {
const targetPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
const code = this.generateCode();
const key = cleanId;
const key = `pt_otp:${targetPhone}`;
OTP_CACHE.set(key, {
code,
app: 'pacifictrans',
identifier: cleanId,
phone: targetPhone,
email: email ? email.trim().toLowerCase() : undefined,
@ -125,19 +127,24 @@ export class AuthService {
error?: string;
}> {
const cleanId = params.identifier.trim().toLowerCase();
const record = OTP_CACHE.get(cleanId);
const digits = cleanId.replace(/\D/g, '');
const normalizedPhone = digits.startsWith('57') ? digits : `57${digits}`;
const key = `pt_otp:${normalizedPhone}`;
const record = OTP_CACHE.get(key) || OTP_CACHE.get(cleanId);
if (!record) {
return { success: false, error: 'Código expirado o no solicitado. Solicita uno nuevo.' };
if (!record || (record.app && record.app !== 'pacifictrans')) {
return { success: false, error: 'Código expirado o no solicitado en Pacific Trans GO. Solicita uno nuevo.' };
}
if (Date.now() > record.expiresAt) {
OTP_CACHE.delete(key);
OTP_CACHE.delete(cleanId);
return { success: false, error: 'El código ha expirado. Solicita un nuevo código.' };
}
record.attempts++;
if (record.attempts > 4) {
OTP_CACHE.delete(key);
OTP_CACHE.delete(cleanId);
return { success: false, error: 'Demasiados intentos fallidos. Solicita un nuevo código.' };
}
@ -147,6 +154,7 @@ export class AuthService {
}
// OTP Correcto -> Limpiar
OTP_CACHE.delete(key);
OTP_CACHE.delete(cleanId);
const cleanPhone = record.phone.replace(/\D/g, '');
@ -241,12 +249,13 @@ export class AuthService {
const finalUserId = (user?._id || localUser.id).toString();
const finalRole: UserRole = user?.rol || localUser.rol;
// Generar Token de Sesión seguro firmado con expiración exacta de 24 horas
// Generar Token de Sesión seguro firmado con expiración exacta de 24 horas y aislado para Pacific Trans GO
const expiresAt = Date.now() + SESSION_DURATION_MS;
const tokenPayload = {
uid: finalUserId,
phone: cleanPhone,
rol: finalRole,
app: 'pacifictrans',
exp: expiresAt,
};
const b64 = Buffer.from(JSON.stringify(tokenPayload)).toString('base64url');
@ -256,6 +265,7 @@ export class AuthService {
const userPayload = {
id: finalUserId,
_id: finalUserId,
app: 'pacifictrans',
nombre: user?.nombre || localUser.nombre,
telefono: cleanPhone,
email: user?.email || localUser.email,
@ -294,13 +304,19 @@ export class AuthService {
let userId: string | null = null;
let expiresAt: number = 0;
// 1. Validar token firmado criptográficamente con vigencia estricta de 24 horas
// 1. Validar token firmado criptográficamente con vigencia estricta de 24 horas y aislamiento de aplicación
if (!token.startsWith('pt_')) {
return null;
}
if (token.startsWith('pt_') && token.includes('.')) {
try {
const [b64, sig] = token.slice(3).split('.');
const expectedSig = crypto.createHmac('sha256', JWT_SECRET).update(b64).digest('base64url');
if (sig === expectedSig) {
const payload = JSON.parse(Buffer.from(b64, 'base64url').toString('utf8'));
if (payload.app && payload.app !== 'pacifictrans') {
return null;
}
if (Date.now() > payload.exp) {
// El token superó el lapso de 24 horas
console.log(`⏰ [Token Expirado]: El token de usuario ${payload.uid} ha superado las 24 horas.`);

View File

@ -26,6 +26,7 @@ export function isPlatformSuperAdmin(identifier?: string): boolean {
export interface LocalUserRecord {
id: string;
_id: string;
app: 'pacifictrans';
appwriteId: string;
nombre: string;
telefono: string;
@ -79,8 +80,10 @@ class LocalUserStore {
const raw = fs.readFileSync(STORE_PATH, 'utf-8');
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
parsed.forEach((u: LocalUserRecord) => {
if (u && u.id) {
parsed.forEach((u: any) => {
// Aislar estrictamente usuarios de Pacific Trans GO
if (u && u.id && (!u.app || u.app === 'pacifictrans')) {
u.app = 'pacifictrans';
this.usersMap.set(u.id, u);
if (u.telefono) {
const clean = u.telefono.replace(/\D/g, '');
@ -118,6 +121,7 @@ class LocalUserStore {
const superAdminUser: LocalUserRecord = {
id: 'usr-superadmin-platform',
_id: 'usr-superadmin-platform',
app: 'pacifictrans',
appwriteId: 'usr-superadmin-platform',
nombre: 'Superadministrador Principal',
telefono: cleanSuper,
@ -176,6 +180,7 @@ class LocalUserStore {
const record: LocalUserRecord = {
id: userId,
_id: userId,
app: 'pacifictrans',
appwriteId: existing?.appwriteId || user.appwriteId || userId,
nombre: isSuper ? 'Superadministrador Principal' : (user.nombre || existing?.nombre || `Usuario ${cleanPhone.slice(-4)}`),
telefono: cleanPhone,
@ -200,10 +205,59 @@ class LocalUserStore {
this.usersMap.set(userId, record);
this.usersMap.set(`phone:${cleanPhone}`, record);
this.persist();
this.syncToAppwritePtUsers(record);
return record;
}
private syncToAppwritePtUsers(record: LocalUserRecord): void {
const endpoint = process.env.APPWRITE_ENDPOINT || 'https://nyc.cloud.appwrite.io/v1';
const projectId = process.env.APPWRITE_PROJECT_ID || '6aef63d9002c07371b40';
const apiKey = process.env.APPWRITE_API_KEY || 'standard_79704037e869df69b7efcecdf51370360781c1b335d8a16148639c71382d8c9e94c4d49d27c2da0b8dde8e430084b895cc9756d8bb2cf927a4e96c59e3258d79d1119dac4167cee6b1e737a3444982e16187842cfffde1cc8c07c2f3c48d032ec1c164a802deacf6393bedd815b2868cf7faf374815aebe72777c4616d26db40';
const rowId = (record.id || `pt-${record.telefono}`).replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 36);
const dataPayload = {
app: 'pacifictrans',
nombre: record.nombre,
telefono: record.telefono,
email: record.email || '',
documentoIdentidad: record.documentoIdentidad || '',
direccionTexto: record.direccionTexto || 'Cali, Valle del Cauca',
ciudad: record.ciudad || 'Cali',
rol: record.rol,
esSuperAdmin: Boolean(record.esSuperAdmin),
phoneVerified: Boolean(record.phoneVerified),
isActive: record.isActive !== false,
saldoPenalidad: Number(record.saldoPenalidad || 0),
totalViajesCompletados: Number(record.totalViajesCompletados || 0),
totalViajesCancelados: Number(record.totalViajesCancelados || 0),
lastLogin: record.lastLogin || '',
createdAt: record.createdAt || new Date().toISOString(),
};
fetch(`${endpoint}/tablesdb/almeira/tables/pt_users/rows`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': projectId,
'X-Appwrite-Key': apiKey,
},
body: JSON.stringify({ rowId, data: dataPayload }),
})
.then(async (res) => {
if (res.status === 409) {
await fetch(`${endpoint}/tablesdb/almeira/tables/pt_users/rows/${rowId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': projectId,
'X-Appwrite-Key': apiKey,
},
body: JSON.stringify({ data: dataPayload }),
});
}
})
.catch(() => {});
}
public getAll(): LocalUserRecord[] {
this.ensureInitialized();
const unique = Array.from(new Set(this.usersMap.values()));