feat: add app-level authentication foundation

This commit is contained in:
a.tolmachev
2026-03-30 23:47:09 +03:00
parent 91854a4153
commit ab2e603997
42 changed files with 1624 additions and 236 deletions
+28 -6
View File
@@ -1,5 +1,6 @@
(function() {
var API_BASE = '/api/admin';
var AUTH_BASE = '/api/auth';
function headers(extra) {
return Object.assign({
@@ -8,7 +9,7 @@
}
async function request(path, options) {
var response = await fetch(API_BASE + path, Object.assign({
var response = await fetch(path, Object.assign({
credentials: 'same-origin',
headers: headers(),
}, options || {}));
@@ -27,6 +28,9 @@
}
if (!response.ok) {
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
window.CrankAuth.handleUnauthorized();
}
var message = payload && payload.error
? payload.error.message
: payload && payload.message
@@ -44,11 +48,11 @@
}
function get(path) {
return request(path);
return request(API_BASE + path);
}
function post(path, body) {
return request(path, {
return request(API_BASE + path, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
@@ -56,7 +60,7 @@
}
function patch(path, body) {
return request(path, {
return request(API_BASE + path, {
method: 'PATCH',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
@@ -64,7 +68,7 @@
}
function del(path) {
return request(path, { method: 'DELETE' });
return request(API_BASE + path, { method: 'DELETE' });
}
function query(params) {
@@ -81,7 +85,7 @@
}
function postBytes(path, bytes, fileName) {
return request(path, {
return request(API_BASE + path, {
method: 'POST',
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
body: bytes,
@@ -89,6 +93,23 @@
}
window.CrankApi = {
login: function(payload) {
return request(AUTH_BASE + '/login', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
},
logout: function() {
return request(AUTH_BASE + '/logout', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({}),
});
},
getSession: function() {
return request(AUTH_BASE + '/session');
},
listWorkspaces: function() {
return get('/workspaces');
},
@@ -197,4 +218,5 @@
);
},
};
}());
+143
View File
@@ -0,0 +1,143 @@
(function() {
var STORAGE_KEY = 'crank_user';
var sessionCache = null;
var sessionPromise = null;
function isLoginPage() {
return /\/html\/login\.html$/.test(window.location.pathname);
}
function loginUrl() {
return (window.APP_BASE || '') + 'html/login.html';
}
function homeUrl() {
return (window.APP_BASE || '') + 'index.html';
}
function clearUserMirror() {
sessionCache = null;
sessionPromise = null;
try {
localStorage.removeItem(STORAGE_KEY);
} catch (_error) {}
}
function primaryMembership(session) {
return session && session.memberships && session.memberships.length
? session.memberships[0]
: null;
}
function initials(displayName, email) {
var source = displayName || email || 'Crank';
return source
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map(function(part) { return part.charAt(0).toUpperCase(); })
.join('') || 'CR';
}
function persistUserMirror(session) {
var membership = primaryMembership(session);
var user = {
id: session.user.id,
name: session.user.display_name,
email: session.user.email,
role: membership ? String(membership.role || '').replace(/^\w/, function(char) { return char.toUpperCase(); }) : 'Viewer',
workspace: membership ? membership.workspace.slug : '',
workspaceId: membership ? membership.workspace.id : '',
initials: initials(session.user.display_name, session.user.email),
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} catch (_error) {}
}
async function fetchSession(force) {
if (!force && sessionCache) {
return sessionCache;
}
if (!force && sessionPromise) {
return sessionPromise;
}
sessionPromise = window.CrankApi.getSession()
.then(function(session) {
sessionCache = session;
persistUserMirror(session);
return session;
})
.catch(function(error) {
clearUserMirror();
throw error;
})
.finally(function() {
sessionPromise = null;
});
return sessionPromise;
}
async function guardProtectedPage() {
try {
return await fetchSession(false);
} catch (error) {
if (error && error.status === 401) {
window.location.replace(loginUrl());
return null;
}
throw error;
}
}
async function guardLoginPage() {
try {
await fetchSession(false);
window.location.replace(homeUrl());
} catch (error) {
if (!error || error.status !== 401) {
throw error;
}
}
}
async function login(email, password) {
var session = await window.CrankApi.login({
email: email,
password: password,
});
sessionCache = session;
persistUserMirror(session);
window.location.href = homeUrl();
}
async function logout() {
try {
await window.CrankApi.logout();
} finally {
clearUserMirror();
window.location.href = loginUrl();
}
}
function handleUnauthorized() {
if (isLoginPage()) {
clearUserMirror();
return;
}
clearUserMirror();
window.location.replace(loginUrl());
}
window.CrankAuth = {
fetchSession: fetchSession,
guardProtectedPage: guardProtectedPage,
guardLoginPage: guardLoginPage,
login: login,
logout: logout,
handleUnauthorized: handleUnauthorized,
};
}());
+1 -2
View File
@@ -423,8 +423,7 @@ document.addEventListener('alpine:init', function() {
},
handleLogout() {
localStorage.removeItem('crank_user');
window.location.href = (window.APP_BASE || '') + 'html/login.html';
window.CrankAuth.logout();
},
};
});
+40 -32
View File
@@ -1,34 +1,42 @@
// If already logged in, redirect to home
try {
if (localStorage.getItem('crank_user')) {
window.location.replace((window.APP_BASE||'')+'index.html');
}
} catch (e) {}
document.getElementById('login-form').addEventListener('submit', function (e) {
e.preventDefault();
var email = document.getElementById('email').value.trim();
var password = document.getElementById('password').value;
var errEl = document.getElementById('login-error');
if (!email || !password) {
errEl.style.display = 'block';
errEl.textContent = 'Please enter your email and password.';
return;
(function() {
function showError(message) {
var errorElement = document.getElementById('login-error');
errorElement.style.display = 'block';
errorElement.textContent = message;
}
// Demo: any non-empty credentials succeed
errEl.style.display = 'none';
var initials = email.split('@')[0].slice(0, 2).toUpperCase();
var displayName = email.split('@')[0].replace(/[._]/g, ' ')
.split(' ').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' ');
var user = {
name: displayName,
email: email,
role: 'Admin',
workspace: 'acme-workspace',
initials: initials,
};
try { localStorage.setItem('crank_user', JSON.stringify(user)); } catch (ex) {}
window.location.href = (window.APP_BASE||'')+'index.html';
});
function hideError() {
var errorElement = document.getElementById('login-error');
errorElement.style.display = 'none';
}
document.addEventListener('DOMContentLoaded', function() {
window.CrankAuth.guardLoginPage().catch(function(error) {
console.error(error);
});
document.getElementById('login-form').addEventListener('submit', async function(event) {
event.preventDefault();
var email = document.getElementById('email').value.trim();
var password = document.getElementById('password').value;
if (!email || !password) {
showError('Please enter your email and password.');
return;
}
hideError();
try {
await window.CrankAuth.login(email, password);
} catch (error) {
if (error && error.status === 401) {
showError('Invalid email or password. Please try again.');
return;
}
showError('Unable to sign in right now. Please try again.');
}
});
});
}());
+60 -56
View File
@@ -1,94 +1,99 @@
/**
* nav.js — shared navbar logic for non-catalog pages
* Handles: auth guard, user dropdown, hamburger, logout
*/
(function () {
/* ── Auth guard ── */
var STORAGE_KEY = 'crank_user';
var user = null;
try { user = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) {}
if (!user) {
var path = window.location.pathname;
var isLogin = path.endsWith('login.html') || path.endsWith('/login');
if (!isLogin) { window.location.replace((window.APP_BASE||'')+'html/login.html'); return; }
function currentUser() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY));
} catch (_error) {
return null;
}
}
/* ── Fill user info once DOM is available ── */
function fillUserInfo() {
if (!user) return;
document.querySelectorAll('.nav-avatar').forEach(function (el) {
el.textContent = user.initials || 'AT';
var user = currentUser();
if (!user) {
return;
}
document.querySelectorAll('.nav-avatar').forEach(function (element) {
element.textContent = user.initials || 'CR';
});
var nameEl = document.querySelector('.user-dropdown-name');
var roleEl = document.querySelector('.user-dropdown-role');
if (nameEl) nameEl.textContent = user.name || 'Operator';
if (roleEl) roleEl.textContent = (user.role || 'Admin') + ' · ' + (user.workspace || 'workspace');
var nameElement = document.querySelector('.user-dropdown-name');
var roleElement = document.querySelector('.user-dropdown-role');
if (nameElement) {
nameElement.textContent = user.name || 'Operator';
}
if (roleElement) {
roleElement.textContent = (user.role || 'Viewer') + ' · ' + (user.workspace || 'workspace');
}
}
/* ── Wire up nav interactions ── */
function initNav() {
fillUserInfo();
var dropdown = document.querySelector('.user-dropdown');
var avatar = document.querySelector('.nav-avatar');
var dropdown = document.querySelector('.user-dropdown');
var avatar = document.querySelector('.nav-avatar');
var hamburger = document.querySelector('.nav-hamburger');
var mobileNav = document.querySelector('.mobile-nav');
// Initially hide elements controlled by JS
if (dropdown) dropdown.style.display = 'none';
if (mobileNav) mobileNav.style.display = 'none';
if (dropdown) {
dropdown.style.display = 'none';
}
if (mobileNav) {
mobileNav.style.display = 'none';
}
// User dropdown toggle
if (avatar && dropdown) {
avatar.addEventListener('click', function (e) {
e.stopPropagation();
var showing = dropdown.style.display !== 'none';
dropdown.style.display = showing ? 'none' : 'block';
avatar.addEventListener('click', function (event) {
event.stopPropagation();
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
});
}
// Hamburger toggle
if (hamburger && mobileNav) {
hamburger.addEventListener('click', function (e) {
e.stopPropagation();
var showing = mobileNav.style.display !== 'none';
mobileNav.style.display = showing ? 'none' : 'block';
hamburger.classList.toggle('open', !showing);
hamburger.addEventListener('click', function (event) {
event.stopPropagation();
mobileNav.style.display = mobileNav.style.display === 'none' ? 'block' : 'none';
hamburger.classList.toggle('open', mobileNav.style.display !== 'none');
});
}
// Close dropdown / mobile-nav on outside click
document.addEventListener('click', function (e) {
if (dropdown && !e.target.closest('.user-menu')) {
document.addEventListener('click', function (event) {
if (dropdown && !event.target.closest('.user-menu')) {
dropdown.style.display = 'none';
}
if (mobileNav && !e.target.closest('.navbar') && !e.target.closest('.mobile-nav')) {
if (mobileNav && !event.target.closest('.navbar') && !event.target.closest('.mobile-nav')) {
mobileNav.style.display = 'none';
if (hamburger) hamburger.classList.remove('open');
if (hamburger) {
hamburger.classList.remove('open');
}
}
});
// Action buttons inside dropdown
document.querySelectorAll('[data-action="logout"]').forEach(function (btn) {
btn.addEventListener('click', function () {
localStorage.removeItem(STORAGE_KEY);
window.location.href = (window.APP_BASE||'')+'html/login.html';
document.querySelectorAll('[data-action="logout"]').forEach(function (button) {
button.addEventListener('click', function () {
window.CrankAuth.logout();
});
});
document.querySelectorAll('[data-action="profile"]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (dropdown) dropdown.style.display = 'none';
window.location.href = (window.APP_BASE||'')+'html/settings.html#profile';
document.querySelectorAll('[data-action="profile"]').forEach(function (button) {
button.addEventListener('click', function () {
if (dropdown) {
dropdown.style.display = 'none';
}
window.location.href = (window.APP_BASE || '') + 'html/settings.html#profile';
});
});
document.querySelectorAll('[data-action="settings-link"]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (dropdown) dropdown.style.display = 'none';
window.location.href = (window.APP_BASE||'')+'html/settings.html';
document.querySelectorAll('[data-action="settings-link"]').forEach(function (button) {
button.addEventListener('click', function () {
if (dropdown) {
dropdown.style.display = 'none';
}
window.location.href = (window.APP_BASE || '') + 'html/settings.html';
});
});
}
@@ -98,5 +103,4 @@
} else {
initNav();
}
})();