Add real user auth (register/login) + admin UI login
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
48
src/core/entities/user.entity.ts
Normal file
48
src/core/entities/user.entity.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
34
src/core/services/user.service.ts
Normal file
34
src/core/services/user.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user