feat: add app-level authentication foundation
This commit is contained in:
+28
-6
@@ -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 @@
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
}());
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}());
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user