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>
178 lines
5.9 KiB
JavaScript
Executable File
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();
|