Add real user auth (register/login) + admin UI login
This commit is contained in:
@@ -34,23 +34,81 @@
|
||||
<h1>Admin Panel</h1>
|
||||
<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>
|
||||
Tenant ID:
|
||||
<input id="tenantId" value="default" />
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<div id="modules"></div>
|
||||
|
||||
<div id="module-detail"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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 modulesContainer = document.getElementById('modules');
|
||||
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) {
|
||||
const tenantId = tenantInput.value || 'default';
|
||||
@@ -59,20 +117,91 @@
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const statusEl = document.getElementById('status');
|
||||
statusEl.textContent = 'Consultando ' + path + '...';
|
||||
setStatus('Consultando ' + path + '...');
|
||||
if (authToken) {
|
||||
options.headers = {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(withTenant(`${apiRoot}${path}`), options);
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const err = data?.message ? data.message : res.statusText;
|
||||
statusEl.textContent = `Error ${res.status}`;
|
||||
setStatus(`Error ${res.status}`);
|
||||
throw new Error(err || `HTTP ${res.status}`);
|
||||
}
|
||||
statusEl.textContent = 'OK';
|
||||
setTimeout(() => (statusEl.textContent = ''), 1500);
|
||||
setStatus('OK');
|
||||
setTimeout(() => setStatus(''), 1500);
|
||||
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) {
|
||||
return `<pre style="white-space: pre-wrap; word-break: break-word;">${JSON.stringify(value, null, 2)}</pre>`;
|
||||
}
|
||||
@@ -341,10 +470,17 @@
|
||||
detailContainer.appendChild(panel);
|
||||
}
|
||||
|
||||
loginBtn.addEventListener('click', login);
|
||||
registerBtn.addEventListener('click', register);
|
||||
logoutBtn.addEventListener('click', logout);
|
||||
document.getElementById('refresh').addEventListener('click', loadModules);
|
||||
tenantInput.addEventListener('change', loadModules);
|
||||
|
||||
loadModules();
|
||||
if (authToken) {
|
||||
showMain();
|
||||
} else {
|
||||
showAuth();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { TokenService } from '../services/token.service';
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { UserService } from '../services/user.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly tokenService: TokenService) {}
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@Post('token')
|
||||
createToken(@Body() body: { name?: string; expiresInHours?: number }) {
|
||||
return this.tokenService.createToken(body.name, body.expiresInHours);
|
||||
@Post('register')
|
||||
async register(
|
||||
@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 { AuthService } from './services/auth.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
@@ -14,18 +18,32 @@ export class AuthGuard implements CanActivate {
|
||||
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 token = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const payload = this.authService.verify(token.replace(/^Bearer\s+/i, ''));
|
||||
if (!payload) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const raw = token.replace(/^Bearer\s+/i, '');
|
||||
|
||||
// Try JWT first, then fallback to simple token-based auth.
|
||||
const payload = this.authService.verify(raw);
|
||||
if (payload) {
|
||||
request.user = payload;
|
||||
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 { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { UserService } from './services/user.service';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { UserSchema } from './entities/user.entity';
|
||||
|
||||
@Module({
|
||||
providers: [AuthService, TokenService, AuthGuard],
|
||||
exports: [AuthService, TokenService, AuthGuard],
|
||||
imports: [MikroOrmModule.forFeature([UserSchema])],
|
||||
providers: [AuthService, TokenService, UserService, AuthGuard],
|
||||
exports: [AuthService, TokenService, UserService, AuthGuard],
|
||||
})
|
||||
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