ledgerflow-frontend/login.html
Adam Moussa 5708347c62 Add PO pagination and load all POs from backend
Purchase orders page now shows 20 items per page with page navigation
controls. loadPOs() fetches all pages via cursor pagination instead of
capping at 200. Search filters the full dataset and resets to page 1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 13:58:28 -04:00

407 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LedgerFlow — Sign In</title>
<!-- Config (API URL + Client ID) -->
<script src="config.js"></script>
<!-- Google Identity Services -->
<script src="https://accounts.google.com/gsi/client" async defer onload="onGisLoaded()"></script>
<style>
:root {
--bg: #0a0c0f;
--surface: #111318;
--surface2: #181c24;
--border: #1f2533;
--border2: #2a3348;
--accent: #00e5a0;
--text: #e8edf5;
--text2: #8892a4;
--text3: #4a5568;
--mono: 'DM Mono', monospace;
--sans: 'Syne', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--mono), monospace;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
/* Animated background grid */
body::before {
content: '';
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,229,160,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,229,160,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 16px;
padding: 48px 44px;
width: 400px;
text-align: center;
position: relative;
box-shadow: 0 0 80px rgba(0,229,160,0.05), 0 24px 60px rgba(0,0,0,0.5);
}
.login-card::before {
content: '';
position: absolute;
top: 0; left: 10%; right: 10%;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
}
.logo-mark {
font-family: var(--sans), sans-serif;
font-weight: 800;
font-size: 28px;
color: var(--accent);
letter-spacing: -1px;
margin-bottom: 4px;
}
.logo-sub {
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 36px;
}
.login-title {
font-family: var(--sans), sans-serif;
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
}
.login-sub {
font-size: 12px;
color: var(--text2);
margin-bottom: 32px;
line-height: 1.6;
}
/* Google button container — Google renders into this */
#google-btn-container {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
/* Fallback manual button */
.google-btn-fallback {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 11px 22px;
background: #fff;
border: none;
border-radius: 8px;
font-family: var(--mono), monospace;
font-size: 13px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: box-shadow 0.15s;
}
.google-btn-fallback:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.3); }
.google-btn-fallback svg { flex-shrink: 0; }
.login-footer {
font-size: 10px;
color: var(--text3);
line-height: 1.7;
}
.login-footer a { color: var(--text2); text-decoration: none; }
.login-footer a:hover { color: var(--accent); }
.loading {
display: none;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
}
.spinner {
width: 28px; height: 28px;
border: 2px solid var(--border2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-box {
display: none;
background: rgba(255,71,87,0.1);
border: 1px solid rgba(255,71,87,0.3);
border-radius: 8px;
padding: 10px 14px;
font-size: 12px;
color: #ff8090;
margin-top: 12px;
text-align: left;
}
</style>
</head>
<body>
<div class="login-card">
<div class="logo-mark">LedgerFlow</div>
<div class="logo-sub">B2B Accounting</div>
<div id="login-content">
<div class="login-title">Welcome back</div>
<div class="login-sub">Sign in with your company Google account<br>to access your accounting workspace.</div>
<div id="google-btn-container">
<!-- Google Identity Services renders here automatically -->
<!-- Fallback shown until GIS loads -->
<button class="google-btn-fallback" id="fallback-btn" onclick="triggerGoogleLogin()">
<svg width="18" height="18" viewBox="0 0 18 18">
<path fill="#4285F4" d="M16.51 8H8.98v3h4.3c-.18 1-.74 1.48-1.6 2.04v2.01h2.6a7.8 7.8 0 0 0 2.38-5.88c0-.57-.05-.66-.15-1.18z"/>
<path fill="#34A853" d="M8.98 17c2.16 0 3.97-.72 5.3-1.94l-2.6-2.04c-.72.48-1.63.8-2.7.8-2.08 0-3.84-1.4-4.47-3.29H1.83v2.07A8 8 0 0 0 8.98 17z"/>
<path fill="#FBBC05" d="M4.51 10.53A4.8 4.8 0 0 1 4.26 9c0-.53.09-1.04.25-1.53V5.4H1.83A8 8 0 0 0 .98 9c0 1.29.31 2.51.85 3.6l2.68-2.07z"/>
<path fill="#EA4335" d="M8.98 3.58c1.17 0 2.23.4 3.06 1.2l2.3-2.3A8 8 0 0 0 .98 9h2.83c.63-1.89 2.4-3.29 4.47-3.42h.7z"/>
</svg>
Continue with Google
</button>
</div>
<div class="error-box" id="error-box"></div>
<div class="login-footer">
By signing in you agree to LedgerFlow's terms of service.<br>
Your credentials are validated by Google — we never see your password.
</div>
</div>
<div class="loading" id="loading-state">
<div class="spinner"></div>
<span style="font-size:12px;color:var(--text2)" id="loading-msg">Signing you in...</span>
</div>
</div>
<script>
// ─── Configuration ────────────────────────────────────────────────────────────
// Set LEDGERFLOW_API_URL to your API Gateway endpoint after deploying.
// Set GOOGLE_CLIENT_ID to your Google OAuth client ID.
const API_URL = window.LEDGERFLOW_API_URL || "";
const GOOGLE_CLIENT_ID = window.GOOGLE_CLIENT_ID || "";
// ─── Google Identity Services Init ───────────────────────────────────────────
let _pendingClientId = null;
window.addEventListener("load", async () => {
// Fetch Google Client ID from backend config endpoint
try {
if (API_URL) {
const res = await fetch(`${API_URL}/auth/config`);
const cfg = await res.json();
if (cfg.googleClientId) initGoogleSignIn(cfg.googleClientId);
} else if (GOOGLE_CLIENT_ID) {
initGoogleSignIn(GOOGLE_CLIENT_ID);
}
} catch (e) {
console.warn("Could not load auth config:", e);
}
});
function initGoogleSignIn(clientId) {
if (!window.google?.accounts) {
// GIS script not yet loaded — store client ID and retry when it loads
_pendingClientId = clientId;
return;
}
google.accounts.id.initialize({
client_id: clientId,
callback: handleCredentialResponse,
auto_select: false,
cancel_on_tap_outside: true,
});
// Render the styled Google button
google.accounts.id.renderButton(
document.getElementById("google-btn-container"),
{
theme: "filled_black",
size: "large",
text: "continue_with",
shape: "rectangular",
width: 280,
}
);
// Hide the fallback button
document.getElementById("fallback-btn").style.display = "none";
// Show One Tap prompt
google.accounts.id.prompt();
}
// Called when the GIS script finishes loading — retries init if we already have a client ID
function onGisLoaded() {
if (_pendingClientId) initGoogleSignIn(_pendingClientId);
}
function triggerGoogleLogin() {
if (window.google?.accounts?.id) {
google.accounts.id.prompt();
} else {
showError("Google Sign-In is loading. Please try again in a moment.");
}
}
// ─── Handle Google Credential Response ───────────────────────────────────────
async function handleCredentialResponse(response) {
const idToken = response.credential;
showLoading("Verifying your account...");
try {
// Verify token with our backend and get user profile
const res = await fetch(`${API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${idToken}` },
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || "Authentication failed");
}
const user = await res.json();
// Store session client-side (token + user info)
sessionStorage.setItem("ledgerflow_token", idToken);
sessionStorage.setItem("ledgerflow_user", JSON.stringify(user));
showLoading(`Welcome, ${user.name}! Loading your workspace...`);
// Redirect to main app after short delay
setTimeout(() => {
window.location.href = "accounting-app.html";
}, 800);
} catch (err) {
hideLoading();
showError(err.message || "Sign-in failed. Please try again.");
}
}
// ─── API Client (used in main app) ───────────────────────────────────────────
// Export this as a module or include it in your main app HTML.
window.LedgerFlowAPI = {
_token() {
return sessionStorage.getItem("ledgerflow_token");
},
_user() {
try { return JSON.parse(sessionStorage.getItem("ledgerflow_user")); } catch { return null; }
},
async _fetch(path, opts = {}) {
const token = this._token();
if (!token) { window.location.href = "index.html"; throw new Error("Not authenticated"); }
const res = await fetch(`${API_URL}${path}`, {
...opts,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(opts.headers || {}),
},
});
if (res.status === 401) {
// Token expired — redirect to login
sessionStorage.clear();
window.location.href = "index.html";
throw new Error("Session expired");
}
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
},
// Purchase Orders
pos: {
list: (qs = {}) => window.LedgerFlowAPI._fetch("/pos?" + new URLSearchParams(qs)),
get: (id) => window.LedgerFlowAPI._fetch(`/pos/${id}`),
create: (body) => window.LedgerFlowAPI._fetch("/pos", { method: "POST", body: JSON.stringify(body) }),
update: (id, body) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "PUT", body: JSON.stringify(body) }),
patch: (id, body) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
delete: (id) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "DELETE" }),
import: (items, fieldMap, source) =>
window.LedgerFlowAPI._fetch("/pos/import", { method: "POST", body: JSON.stringify({ items, fieldMap, source }) }),
},
// Invoices
invoices: {
list: (qs = {}) => window.LedgerFlowAPI._fetch("/invoices?" + new URLSearchParams(qs)),
get: (id) => window.LedgerFlowAPI._fetch(`/invoices/${id}`),
create: (body) => window.LedgerFlowAPI._fetch("/invoices", { method: "POST", body: JSON.stringify(body) }),
update: (id, body) => window.LedgerFlowAPI._fetch(`/invoices/${id}`, { method: "PUT", body: JSON.stringify(body) }),
updateStatus: (id, status) => window.LedgerFlowAPI._fetch(`/invoices/${id}/status`, { method: "PATCH", body: JSON.stringify({ status }) }),
delete: (id) => window.LedgerFlowAPI._fetch(`/invoices/${id}`, { method: "DELETE" }),
},
// EDI
edi: {
submit: (body) => window.LedgerFlowAPI._fetch("/edi/submit", { method: "POST", body: JSON.stringify(body) }),
transactions: (qs={}) => window.LedgerFlowAPI._fetch("/edi/transactions?" + new URLSearchParams(qs)),
getTransaction: (id) => window.LedgerFlowAPI._fetch(`/edi/transactions/${id}`),
pollStatus: (isa) => window.LedgerFlowAPI._fetch(`/edi/status/${isa}`),
},
// Auth
logout: async () => {
const token = sessionStorage.getItem("ledgerflow_token");
if (token) {
await fetch(`${API_URL}/auth/logout`, { method: "POST", headers: { Authorization: `Bearer ${token}` } })
.catch(() => {});
}
if (window.google?.accounts?.id) google.accounts.id.disableAutoSelect();
sessionStorage.clear();
window.location.href = "index.html";
},
};
// ─── UI Helpers ───────────────────────────────────────────────────────────────
function showLoading(msg = "Loading...") {
document.getElementById("login-content").style.display = "none";
document.getElementById("loading-state").style.display = "flex";
document.getElementById("loading-msg").textContent = msg;
}
function hideLoading() {
document.getElementById("login-content").style.display = "block";
document.getElementById("loading-state").style.display = "none";
}
function showError(msg) {
const box = document.getElementById("error-box");
box.style.display = "block";
box.textContent = "⚠ " + msg;
}
</script>
</body>
</html>