Add user database migration, core reusable components, and layout structure
This commit is contained in:
+126
-74
@@ -1,84 +1,136 @@
|
||||
import { setToken } from './lib/api.mjs';
|
||||
import './components/wo-list.mjs';
|
||||
import './components/wo-form.mjs';
|
||||
// ── Register all custom elements ──────────────────────────────────────────────
|
||||
import './components/shared/ui-badge.mjs';
|
||||
import './components/shared/ui-button.mjs';
|
||||
import './components/shared/ui-spinner.mjs';
|
||||
import './components/shared/ui-toast.mjs';
|
||||
import './components/shared/ui-empty.mjs';
|
||||
import './components/shared/ui-dialog.mjs';
|
||||
import './components/layout/app-root.mjs';
|
||||
import './components/layout/app-sidebar.mjs';
|
||||
import './components/layout/app-topbar.mjs';
|
||||
import './components/layout/app-mobile-nav.mjs';
|
||||
import './components/work-orders/wo-list.mjs';
|
||||
import './components/work-orders/wo-kanban.mjs';
|
||||
import './components/work-orders/wo-form.mjs';
|
||||
import './components/work-orders/wo-detail.mjs';
|
||||
|
||||
// ── Keycloak init ─────────────────────────────────────────────────────────────
|
||||
const keycloak = new Keycloak({
|
||||
url: window.KEYCLOAK_URL || 'http://localhost:8180',
|
||||
realm: window.KEYCLOAK_REALM || 'workorders',
|
||||
clientId: window.KEYCLOAK_CLIENT_ID || 'workorders-app',
|
||||
});
|
||||
import { getUser, setToken, clearToken } from './lib/auth.mjs';
|
||||
import { api } from './lib/api.mjs';
|
||||
import { router } from './lib/router.mjs';
|
||||
import { showToast } from './components/shared/ui-toast.mjs';
|
||||
|
||||
keycloak.init({
|
||||
onLoad: 'login-required',
|
||||
pkceMethod: 'S256',
|
||||
checkLoginIframe: false,
|
||||
})
|
||||
.then(authenticated => {
|
||||
if (!authenticated) { keycloak.login(); return; }
|
||||
setToken(keycloak.token);
|
||||
const root = document.getElementById('root');
|
||||
|
||||
// Refresh token before it expires
|
||||
setInterval(async () => {
|
||||
try { await keycloak.updateToken(60); setToken(keycloak.token); }
|
||||
catch { keycloak.login(); }
|
||||
}, 30_000);
|
||||
window.addEventListener('auth:expired', () => { clearToken(); showLoginPage(); });
|
||||
window.addEventListener('auth:logout', () => { clearToken(); showLoginPage(); });
|
||||
|
||||
window.addEventListener('auth:expired', () => keycloak.login());
|
||||
renderApp(keycloak);
|
||||
})
|
||||
.catch(() => document.getElementById('app').innerHTML =
|
||||
'<p style="color:red;padding:2rem">Failed to connect to authentication server.</p>');
|
||||
|
||||
// ── App shell ─────────────────────────────────────────────────────────────────
|
||||
function renderApp(kc) {
|
||||
const app = document.getElementById('app');
|
||||
const userName = kc.tokenParsed?.name || kc.tokenParsed?.preferred_username || 'User';
|
||||
|
||||
// Nav
|
||||
const nav = document.createElement('nav');
|
||||
nav.innerHTML = `
|
||||
<span class="brand">Work Orders</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="user-info">${userName}</span>
|
||||
<button id="logout" style="background:transparent;border:1px solid rgba(255,255,255,.4);color:#fff;padding:.3rem .8rem;font-size:.85rem">Logout</button>`;
|
||||
document.body.insertBefore(nav, app);
|
||||
nav.querySelector('#logout').addEventListener('click', () => kc.logout());
|
||||
|
||||
showList();
|
||||
|
||||
app.addEventListener('wo:select', e => showDetail(e.detail.id));
|
||||
app.addEventListener('wo:cancel', () => showList());
|
||||
app.addEventListener('wo:saved', () => showList());
|
||||
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') showList(); });
|
||||
const user = getUser();
|
||||
if (user) {
|
||||
startApp();
|
||||
} else {
|
||||
showLoginPage();
|
||||
}
|
||||
|
||||
function showList() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Work Orders</h1>
|
||||
<button id="new-wo">+ New Work Order</button>
|
||||
</div>
|
||||
<wo-list id="wo-list"></wo-list>`;
|
||||
app.querySelector('#new-wo').addEventListener('click', showCreate);
|
||||
// ── Login page ────────────────────────────────────────────────────────────────
|
||||
function showLoginPage() {
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
.login-wrap {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); padding: 1rem;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 2.5rem 2rem;
|
||||
width: 100%; max-width: 380px; box-shadow: var(--shadow-md);
|
||||
}
|
||||
.login-brand {
|
||||
display: flex; align-items: center; justify-content: center; gap: .75rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.login-icon {
|
||||
width: 40px; height: 40px; background: var(--teal); border-radius: var(--radius);
|
||||
display: flex; align-items: center; justify-content: center; color: #fff;
|
||||
}
|
||||
.login-title { font-size: 1.25rem; font-weight: 700; color: var(--text); }
|
||||
.login-card form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.login-card label { display: flex; flex-direction: column; gap: .3rem; font-size: .875rem; font-weight: 600; color: var(--text); }
|
||||
.login-card input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .6rem .75rem; background: var(--surface); color: var(--text); font-size: .938rem; width: 100%; transition: border-color .15s; }
|
||||
.login-card input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.15); }
|
||||
.login-btn {
|
||||
background: var(--teal); color: #fff; border: none; border-radius: var(--radius);
|
||||
padding: .65rem; font-size: .938rem; font-weight: 600; cursor: pointer;
|
||||
transition: opacity .15s; display: flex; align-items: center; justify-content: center; gap: .4rem;
|
||||
}
|
||||
.login-btn:hover { opacity: .88; }
|
||||
.login-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.login-error { color: var(--danger); font-size: .813rem; text-align: center; min-height: 1.25rem; }
|
||||
</style>
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="login-icon"><i data-lucide="clipboard-list" style="width:20px;height:20px"></i></div>
|
||||
<span class="login-title">Work Orders</span>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<label>Username or Email<input id="login-user" type="text" autocomplete="username" required placeholder="admin"></label>
|
||||
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required placeholder="••••••••"></label>
|
||||
<div class="login-error" id="login-error"></div>
|
||||
<button type="submit" class="login-btn" id="login-btn">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [root] });
|
||||
|
||||
root.querySelector('#login-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = root.querySelector('#login-btn');
|
||||
const err = root.querySelector('#login-error');
|
||||
const username = root.querySelector('#login-user').value.trim();
|
||||
const password = root.querySelector('#login-pass').value;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Signing in…';
|
||||
err.textContent = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || 'Login failed');
|
||||
setToken(json.data.token);
|
||||
startApp();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign In';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showCreate() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header"><h1>New Work Order</h1></div>
|
||||
<div class="card"><wo-form></wo-form></div>`;
|
||||
}
|
||||
// ── Main app ──────────────────────────────────────────────────────────────────
|
||||
function startApp() {
|
||||
root.innerHTML = '<app-root></app-root>';
|
||||
const appRoot = root.querySelector('app-root');
|
||||
|
||||
function showDetail(id) {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Edit Work Order</h1>
|
||||
<button id="back" class="secondary">← Back</button>
|
||||
</div>
|
||||
<div class="card"><wo-form wo-id="${id}"></wo-form></div>`;
|
||||
app.querySelector('#back').addEventListener('click', showList);
|
||||
router
|
||||
.on('/', () => appRoot.setPage('<wo-list></wo-list>'))
|
||||
.on('/work-orders', () => appRoot.setPage('<wo-list></wo-list>'))
|
||||
.on('/work-orders/new', () => appRoot.setPage('<wo-form></wo-form>'))
|
||||
.on('/work-orders/:id/edit', ({ id }) => appRoot.setPage(`<wo-form wo-id="${id}"></wo-form>`))
|
||||
.on('/work-orders/:id', ({ id }) => appRoot.setPage(`<wo-detail wo-id="${id}"></wo-detail>`))
|
||||
.on('/registry/people', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">People registry — Phase 2</p>'))
|
||||
.on('/registry/vehicles', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Vehicles registry — Phase 2</p>'))
|
||||
.on('/registry/equipment', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Equipment registry — Phase 2</p>'))
|
||||
.on('/registry/materials', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Materials registry — Phase 2</p>'))
|
||||
.on('/reports', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Reports — Phase 3</p>'))
|
||||
.on('/users', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">User management — Phase 3</p>'))
|
||||
.on('/settings', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Settings — Phase 4</p>'))
|
||||
.start();
|
||||
|
||||
// Global navigation events from WO components
|
||||
window.addEventListener('wo:navigate', e => router.navigate(e.detail.path));
|
||||
window.addEventListener('wo:toast', e => showToast(e.detail.message, e.detail.type));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user