Auth: reemplazar Basic Auth por sesión PHP con login propio

- login.html: página de acceso con diseño Quiet Wealth (sin popup del browser)
- api/login.php: endpoint POST para iniciar sesión, GET ?action=logout para cerrarla
- auth.php: valida sesión PHP ($_SESSION) en lugar de HTTP Basic Auth
- ui.js: apiFetch() intercepta 401 y redirige a login.html automáticamente
- Todos los fetch() en dashboard, add y historial migrados a apiFetch()
- Eliminado docker/htpasswd y auth_basic de NGINX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Monla
2026-06-03 16:25:50 -03:00
co-authored by Claude Sonnet 4.6
parent 1244235820
commit ead1bf8ffc
10 changed files with 176 additions and 19 deletions
+6 -7
View File
@@ -1,11 +1,10 @@
<?php
// pagos/api/auth.php
// Las credenciales viven en config.php (gitignoreado). Ver config.example.php.
require_once __DIR__ . '/config.php';
if (!isset($_SERVER['PHP_AUTH_USER']) || $_SERVER['PHP_AUTH_USER'] !== AUTH_USER || $_SERVER['PHP_AUTH_PW'] !== AUTH_PASS) {
header('WWW-Authenticate: Basic realm="Sanctuary Finanzas"');
header('HTTP/1.0 401 Unauthorized');
die("Acceso denegado.");
session_start();
if (!isset($_SESSION['qw_auth'])) {
http_response_code(401);
header('Content-Type: application/json');
die(json_encode(['status' => 'error', 'message' => 'No autenticado']));
}
?>
+27
View File
@@ -0,0 +1,27 @@
<?php
require_once __DIR__ . '/config.php';
session_start();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
if (isset($data['user'], $data['pass'])
&& $data['user'] === AUTH_USER
&& $data['pass'] === AUTH_PASS) {
$_SESSION['qw_auth'] = true;
echo json_encode(['status' => 'success']);
} else {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Usuario o contraseña incorrectos']);
}
exit;
}
if (($_GET['action'] ?? '') === 'logout') {
session_destroy();
echo json_encode(['status' => 'success']);
exit;
}
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Método no permitido']);
+2 -2
View File
@@ -13,7 +13,7 @@ document.addEventListener('DOMContentLoaded', () => {
iptFecha.valueAsDate = new Date();
// Cargar categorías desde la API
fetch('api/movimientos.php')
apiFetch('api/movimientos.php')
.then(r => r.json())
.then(data => {
if (data.categorias) {
@@ -98,7 +98,7 @@ document.addEventListener('DOMContentLoaded', () => {
btnSave.innerHTML = 'Guardando...';
btnSave.disabled = true;
const r = await fetch('api/movimientos.php', {
const r = await apiFetch('api/movimientos.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
+1 -1
View File
@@ -1,6 +1,6 @@
document.addEventListener('DOMContentLoaded', async () => {
try {
const response = await fetch('api/movimientos.php');
const response = await apiFetch('api/movimientos.php');
const data = await response.json();
if (data.status === 'success') {
+2 -2
View File
@@ -8,7 +8,7 @@ async function cargarHistorial() {
const lista = document.getElementById('ui-lista');
try {
const r = await fetch('api/movimientos.php?view=historial');
const r = await apiFetch('api/movimientos.php?view=historial');
const data = await r.json();
loading.classList.add('hidden');
@@ -83,7 +83,7 @@ async function eliminar(id, titulo) {
if (!ok) return;
try {
const r = await fetch('api/movimientos.php', {
const r = await apiFetch('api/movimientos.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: parseInt(id) })
+10
View File
@@ -1,3 +1,13 @@
// Wrapper de fetch que redirige al login si la sesión expiró
window.apiFetch = async function (url, options = {}) {
const r = await fetch(url, options);
if (r.status === 401) {
window.location.href = 'login.html';
return null;
}
return r;
};
(function () {
const MODAL_HTML = `
<div id="rm-modal-overlay" class="fixed inset-0 z-[100] flex items-end justify-center p-6 bg-black/50 backdrop-blur-sm hidden">
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Quiet Wealth — Acceso</title>
<script src="js/tailwind.cdn.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"background": "#f8faf9",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f2f4f3",
"surface-container": "#eceeed",
"on-surface": "#191c1c",
"on-surface-variant": "#3e4948",
"primary": "#005050",
"primary-container": "#006a6a",
"on-primary": "#ffffff",
"error": "#ba1a1a",
"outline": "#6e7979",
"outline-variant": "#bec9c8",
},
fontFamily: { headline: ["Manrope"], body: ["Manrope"] }
}
}
}
</script>
<style>
body { font-family: 'Manrope', sans-serif; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
</style>
</head>
<body class="bg-background min-h-screen flex items-center justify-center px-6">
<div class="w-full max-w-sm space-y-8">
<!-- Logo / título -->
<div class="text-center space-y-3">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-[1.5rem] bg-gradient-to-br from-primary to-primary-container shadow-lg shadow-primary/20">
<span class="material-symbols-outlined text-white text-3xl" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
</div>
<div>
<h1 class="text-2xl font-extrabold text-primary tracking-tight">Quiet Wealth</h1>
<p class="text-sm text-on-surface-variant mt-1">Ingresá para continuar</p>
</div>
</div>
<!-- Formulario -->
<div class="bg-surface-container-lowest rounded-[2rem] p-7 shadow-sm space-y-5">
<div class="space-y-2">
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Usuario</label>
<div class="flex items-center gap-3 bg-surface-container-low px-4 py-3.5 rounded-xl">
<span class="material-symbols-outlined text-outline text-[20px]">person</span>
<input id="inp-user" type="text" autocomplete="username"
class="bg-transparent text-sm font-semibold w-full focus:outline-none text-on-surface placeholder-outline"
placeholder="Usuario"/>
</div>
</div>
<div class="space-y-2">
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Contraseña</label>
<div class="flex items-center gap-3 bg-surface-container-low px-4 py-3.5 rounded-xl">
<span class="material-symbols-outlined text-outline text-[20px]">lock</span>
<input id="inp-pass" type="password" autocomplete="current-password"
class="bg-transparent text-sm font-semibold w-full focus:outline-none text-on-surface placeholder-outline"
placeholder="Contraseña"/>
</div>
</div>
<p id="ui-error" class="hidden text-sm text-error font-semibold text-center pt-1"></p>
<button id="btn-login"
class="w-full h-14 bg-gradient-to-br from-primary to-primary-container text-white rounded-xl font-bold text-base shadow-lg shadow-primary/20 active:scale-[0.98] transition-transform flex items-center justify-center gap-2 mt-2">
<span>Ingresar</span>
<span class="material-symbols-outlined text-[20px]">arrow_forward</span>
</button>
</div>
</div>
<script>
const btnLogin = document.getElementById('btn-login');
const inpUser = document.getElementById('inp-user');
const inpPass = document.getElementById('inp-pass');
const uiError = document.getElementById('ui-error');
async function login() {
uiError.classList.add('hidden');
btnLogin.disabled = true;
btnLogin.innerHTML = '<span>Verificando...</span>';
try {
const r = await fetch('api/login.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: inpUser.value, pass: inpPass.value })
});
const data = await r.json();
if (data.status === 'success') {
window.location.href = 'index.html';
} else {
uiError.textContent = data.message;
uiError.classList.remove('hidden');
btnLogin.disabled = false;
btnLogin.innerHTML = '<span>Ingresar</span><span class="material-symbols-outlined text-[20px]">arrow_forward</span>';
}
} catch {
uiError.textContent = 'Error de conexión con el servidor.';
uiError.classList.remove('hidden');
btnLogin.disabled = false;
btnLogin.innerHTML = '<span>Ingresar</span><span class="material-symbols-outlined text-[20px]">arrow_forward</span>';
}
}
btnLogin.addEventListener('click', login);
document.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
// Si ya está autenticado, redirigir directo
fetch('api/login.php').then(r => {
if (r.status === 200) window.location.href = 'index.html';
}).catch(() => {});
</script>
</body>
</html>