Add real user auth (register/login) + admin UI login

This commit is contained in:
mberlin
2026-03-18 17:47:09 -03:00
parent c31600f544
commit 29d6f902b6
6 changed files with 295 additions and 28 deletions

View File

@@ -34,23 +34,81 @@
<h1>Admin Panel</h1> <h1>Admin Panel</h1>
<p>Modular admin UI: cada módulo se registra automáticamente.</p> <p>Modular admin UI: cada módulo se registra automáticamente.</p>
<div id="auth" class="module">
<h2>Acceso</h2>
<div id="login-form">
<strong>Iniciar sesión</strong><br/>
<input id="loginEmail" placeholder="Email" style="margin-bottom:0.5rem;" />
<input id="loginPassword" type="password" placeholder="Contraseña" style="margin-bottom:0.5rem;" />
<button id="loginBtn">Iniciar sesión</button>
<div id="loginStatus" style="margin-top:0.5rem;color:#555;"></div>
<hr />
<strong>Registrar usuario</strong><br/>
<input id="registerName" placeholder="Nombre (opcional)" style="margin-bottom:0.5rem;" />
<input id="registerEmail" placeholder="Email" style="margin-bottom:0.5rem;" />
<input id="registerPassword" type="password" placeholder="Contraseña" style="margin-bottom:0.5rem;" />
<input id="registerTenant" placeholder="Tenant ID" value="default" style="margin-bottom:0.5rem;" />
<button id="registerBtn">Registrar</button>
</div>
</div>
<div id="main" style="display: none;">
<label> <label>
Tenant ID: Tenant ID:
<input id="tenantId" value="default" /> <input id="tenantId" value="default" />
</label> </label>
<button id="refresh" style="margin-left: 0.5rem;">Actualizar módulos</button> <button id="refresh" style="margin-left: 0.5rem;">Actualizar módulos</button>
<button id="logout" style="margin-left: 0.5rem;">Cerrar sesión</button>
<span id="status" style="margin-left: 1rem; color: #555;"></span> <span id="status" style="margin-left: 1rem; color: #555;"></span>
<div id="modules"></div> <div id="modules"></div>
<div id="module-detail"></div> <div id="module-detail"></div>
</div>
<script> <script>
const apiRoot = '/api/admin'; const apiRoot = '/api/admin';
const authRoot = '/api/auth';
const tokenKey = 'planner_admin_token';
const authPanel = document.getElementById('auth');
const mainPanel = document.getElementById('main');
const tenantInput = document.getElementById('tenantId'); const tenantInput = document.getElementById('tenantId');
const modulesContainer = document.getElementById('modules'); const modulesContainer = document.getElementById('modules');
const detailContainer = document.getElementById('module-detail'); const detailContainer = document.getElementById('module-detail');
const statusEl = document.getElementById('status');
const loginStatus = document.getElementById('loginStatus');
const loginEmail = document.getElementById('loginEmail');
const loginPassword = document.getElementById('loginPassword');
const loginBtn = document.getElementById('loginBtn');
const registerName = document.getElementById('registerName');
const registerEmail = document.getElementById('registerEmail');
const registerPassword = document.getElementById('registerPassword');
const registerTenant = document.getElementById('registerTenant');
const registerBtn = document.getElementById('registerBtn');
const logoutBtn = document.getElementById('logout');
let authToken = localStorage.getItem(tokenKey) || '';
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;
}
function setLoginStatus(msg) {
if (loginStatus) loginStatus.textContent = msg;
}
function setToken(token) {
authToken = token;
if (token) {
localStorage.setItem(tokenKey, token);
} else {
localStorage.removeItem(tokenKey);
}
}
function withTenant(url) { function withTenant(url) {
const tenantId = tenantInput.value || 'default'; const tenantId = tenantInput.value || 'default';
@@ -59,20 +117,91 @@
} }
async function api(path, options = {}) { async function api(path, options = {}) {
const statusEl = document.getElementById('status'); setStatus('Consultando ' + path + '...');
statusEl.textContent = 'Consultando ' + path + '...'; if (authToken) {
options.headers = {
...(options.headers || {}),
Authorization: `Bearer ${authToken}`,
};
}
const res = await fetch(withTenant(`${apiRoot}${path}`), options); const res = await fetch(withTenant(`${apiRoot}${path}`), options);
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {
const err = data?.message ? data.message : res.statusText; const err = data?.message ? data.message : res.statusText;
statusEl.textContent = `Error ${res.status}`; setStatus(`Error ${res.status}`);
throw new Error(err || `HTTP ${res.status}`); throw new Error(err || `HTTP ${res.status}`);
} }
statusEl.textContent = 'OK'; setStatus('OK');
setTimeout(() => (statusEl.textContent = ''), 1500); setTimeout(() => setStatus(''), 1500);
return data; return data;
} }
async function login() {
setLoginStatus('Iniciando sesión...');
try {
const res = await fetch(`${authRoot}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenantId: registerTenant.value || 'default',
email: loginEmail.value,
password: loginPassword.value,
}),
});
const data = await res.json();
if (!res.ok || !data.token) {
setLoginStatus(data.message || 'Credenciales inválidas');
return;
}
setToken(data.token);
setLoginStatus('Sesión iniciada');
showMain();
} catch (err) {
setLoginStatus(err.message || err);
}
}
async function register() {
setLoginStatus('Registrando...');
try {
const res = await fetch(`${authRoot}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenantId: registerTenant.value || 'default',
name: registerName.value,
email: registerEmail.value,
password: registerPassword.value,
}),
});
const data = await res.json();
if (!res.ok) {
setLoginStatus(data.message || 'Error al registrar');
return;
}
setLoginStatus('Registrado. Inicia sesión.');
} catch (err) {
setLoginStatus(err.message || err);
}
}
function logout() {
setToken('');
showAuth();
}
function showAuth() {
if (authPanel) authPanel.style.display = 'block';
if (mainPanel) mainPanel.style.display = 'none';
}
function showMain() {
if (authPanel) authPanel.style.display = 'none';
if (mainPanel) mainPanel.style.display = 'block';
loadModules();
}
function formatJson(value) { function formatJson(value) {
return `<pre style="white-space: pre-wrap; word-break: break-word;">${JSON.stringify(value, null, 2)}</pre>`; return `<pre style="white-space: pre-wrap; word-break: break-word;">${JSON.stringify(value, null, 2)}</pre>`;
} }
@@ -341,10 +470,17 @@
detailContainer.appendChild(panel); detailContainer.appendChild(panel);
} }
loginBtn.addEventListener('click', login);
registerBtn.addEventListener('click', register);
logoutBtn.addEventListener('click', logout);
document.getElementById('refresh').addEventListener('click', loadModules); document.getElementById('refresh').addEventListener('click', loadModules);
tenantInput.addEventListener('change', loadModules); tenantInput.addEventListener('change', loadModules);
loadModules(); if (authToken) {
showMain();
} else {
showAuth();
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,12 +1,39 @@
import { Controller, Post, Body } from '@nestjs/common'; import { Body, Controller, Post } from '@nestjs/common';
import { TokenService } from '../services/token.service'; import { AuthService } from '../services/auth.service';
import { UserService } from '../services/user.service';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly tokenService: TokenService) {} constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
@Post('token') @Post('register')
createToken(@Body() body: { name?: string; expiresInHours?: number }) { async register(
return this.tokenService.createToken(body.name, body.expiresInHours); @Body()
body: {
tenantId?: string;
name?: string;
email: string;
password: string;
},
) {
const tenantId = body.tenantId || process.env.DEFAULT_TENANT_ID || 'default';
const user = await this.userService.createUser(tenantId, body.email, body.password, body.name);
return { id: user.id, email: user.email, name: user.name };
}
@Post('login')
async login(
@Body() body: { tenantId?: string; email: string; password: string },
) {
const tenantId = body.tenantId || process.env.DEFAULT_TENANT_ID || 'default';
const user = await this.userService.validateUser(tenantId, body.email, body.password);
if (!user) {
return { message: 'Invalid credentials' };
}
const token = this.authService.sign({ sub: user.id, name: user.name, tenantId });
return { token };
} }
} }

View File

@@ -1,9 +1,13 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { TokenService } from './services/token.service';
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {} constructor(
private readonly authService: AuthService,
private readonly tokenService: TokenService,
) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
@@ -14,18 +18,32 @@ export class AuthGuard implements CanActivate {
return true; return true;
} }
// Allow auth endpoints (login/register) without a token.
if (request.method === 'POST' && request.url?.startsWith('/api/auth/')) {
return true;
}
const authHeader = request.headers['authorization'] || ''; const authHeader = request.headers['authorization'] || '';
const token = Array.isArray(authHeader) ? authHeader[0] : authHeader; const token = Array.isArray(authHeader) ? authHeader[0] : authHeader;
if (!token) { if (!token) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const payload = this.authService.verify(token.replace(/^Bearer\s+/i, '')); const raw = token.replace(/^Bearer\s+/i, '');
if (!payload) {
throw new UnauthorizedException();
}
// Try JWT first, then fallback to simple token-based auth.
const payload = this.authService.verify(raw);
if (payload) {
request.user = payload; request.user = payload;
return true; return true;
} }
const validToken = this.tokenService.validate(raw);
if (validToken) {
request.user = { token: raw, name: validToken.name };
return true;
}
throw new UnauthorizedException();
}
} }

View File

@@ -1,10 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { TokenService } from './services/token.service'; import { TokenService } from './services/token.service';
import { UserService } from './services/user.service';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
import { UserSchema } from './entities/user.entity';
@Module({ @Module({
providers: [AuthService, TokenService, AuthGuard], imports: [MikroOrmModule.forFeature([UserSchema])],
exports: [AuthService, TokenService, AuthGuard], providers: [AuthService, TokenService, UserService, AuthGuard],
exports: [AuthService, TokenService, UserService, AuthGuard],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -0,0 +1,48 @@
import { EntitySchema } from '@mikro-orm/core';
import { v4 as uuidv4 } from 'uuid';
import { BaseEntity } from './base.entity';
export class User extends BaseEntity {
email!: string;
name?: string;
passwordHash!: string;
}
export const UserSchema = new EntitySchema<User>({
class: User,
tableName: 'users',
properties: {
id: {
type: 'uuid',
primary: true,
default: uuidv4(),
},
tenantId: {
type: 'string',
},
metadata: {
type: 'json',
nullable: true,
},
createdAt: {
type: 'date',
default: new Date(),
},
updatedAt: {
type: 'date',
default: new Date(),
},
email: {
type: 'string',
nullable: false,
},
name: {
type: 'string',
nullable: true,
},
passwordHash: {
type: 'string',
nullable: false,
},
},
});

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { EntityRepository } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import * as bcrypt from 'bcryptjs';
import { User } from '../entities/user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: EntityRepository<User>,
) {}
async createUser(tenantId: string, email: string, password: string, name?: string) {
const existing = await this.userRepository.findOne({ tenantId, email });
if (existing) {
throw new Error('User already exists');
}
const user = new User();
user.tenantId = tenantId;
user.email = email;
user.name = name;
user.passwordHash = await bcrypt.hash(password, 10);
await this.userRepository.persistAndFlush(user);
return user;
}
async validateUser(tenantId: string, email: string, password: string) {
const user = await this.userRepository.findOne({ tenantId, email });
if (!user) return null;
const isValid = await bcrypt.compare(password, user.passwordHash);
return isValid ? user : null;
}
}