librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

178 lines
5.9 KiB
JavaScript
Executable File

class AuthManager {
constructor() {
this.isAuthenticated = false;
this.username = null;
this._resolveLogin = null;
this._overlayEl = null;
}
async initialize() {
const status = await this._checkStatus();
if (status.authenticated) {
this.isAuthenticated = true;
this.username = status.username;
return;
}
return this._showLoginOverlay();
}
async _checkStatus() {
try {
const res = await fetch('/api/auth/status');
return await res.json();
} catch {
return { authenticated: false };
}
}
async login(username, password) {
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok && data.success) {
this.isAuthenticated = true;
this.username = data.username;
this._hideLoginOverlay();
if (this._resolveLogin) this._resolveLogin();
return { success: true };
}
return { success: false, error: data.error || 'Invalid credentials' };
} catch {
return { success: false, error: 'Connection error' };
}
}
async logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch { /* ignore */ }
window.location.reload();
}
_showLoginOverlay() {
return new Promise(resolve => {
this._resolveLogin = resolve;
const overlay = document.createElement('div');
overlay.className = 'login-overlay aurora-bg aurora-static';
overlay.innerHTML = `
<div class="aurora-stars" aria-hidden="true"></div>
<div class="login-content">
<div class="aurora-header">
<div class="aurora-logo">
<img src="/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<h1>LibrePortal</h1>
</div>
<p class="aurora-subtitle">Step softly back into your own private universe</p>
</div>
<div class="login-card">
<form class="login-form" id="login-form" autocomplete="on">
<div class="login-field">
<label class="login-label" for="login-username">Username</label>
<input
class="login-input"
type="text"
id="login-username"
name="username"
placeholder="admin"
autocomplete="username"
required
>
</div>
<div class="login-field">
<label class="login-label" for="login-password">Password</label>
<input
class="login-input"
type="password"
id="login-password"
name="password"
placeholder="••••••••"
autocomplete="current-password"
required
>
</div>
<div class="login-error" id="login-error">
<svg class="login-error-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span class="login-error-text"></span>
</div>
<button class="login-btn" type="submit" id="login-submit">
<span class="login-btn-spinner"></span>
<span class="login-btn-label">Sign In</span>
</button>
</form>
</div>
`;
document.body.appendChild(overlay);
this._overlayEl = overlay;
// Focus username after animation
setTimeout(() => {
document.getElementById('login-username')?.focus();
}, 100);
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const errorEl = document.getElementById('login-error');
const btn = document.getElementById('login-submit');
errorEl.querySelector('.login-error-text').textContent = '';
errorEl.classList.remove('visible');
btn.classList.add('loading');
btn.disabled = true;
const result = await this.login(username, password);
if (!result.success) {
errorEl.querySelector('.login-error-text').textContent = result.error;
errorEl.classList.add('visible');
btn.classList.remove('loading');
btn.disabled = false;
document.getElementById('login-password').value = '';
document.getElementById('login-password').focus();
}
});
});
}
_hideLoginOverlay() {
if (!this._overlayEl) return;
this._overlayEl.classList.add('hiding');
setTimeout(() => {
this._overlayEl?.remove();
this._overlayEl = null;
}, 250);
}
interceptFetch() {
const original = window.fetch.bind(window);
const self = this;
window.fetch = async function(...args) {
// Skip auth endpoints to avoid infinite loops
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if (url.includes('/api/auth/')) return original(...args);
const response = await original(...args);
if (response.status === 401 && self.isAuthenticated) {
self.isAuthenticated = false;
await self._showLoginOverlay();
// Retry the original request after re-login
return original(...args);
}
return response;
};
}
}
window.authManager = new AuthManager();