- Load GSI script dynamically after config.js sets GOOGLE_CLIENT_ID so data-client_id is populated before the library auto-initializes - Fall back to config.js client ID when /auth/config returns empty - Guard against null fallback-btn element in initGoogleSignIn Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
419 lines
14 KiB
HTML
419 lines
14 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 — loaded dynamically after config.js sets GOOGLE_CLIENT_ID -->
|
|
<script>
|
|
// Load GSI only after client ID is available, preventing "Missing required parameter" errors
|
|
(function() {
|
|
var s = document.createElement('script');
|
|
s.src = 'https://accounts.google.com/gsi/client';
|
|
s.async = true;
|
|
s.defer = true;
|
|
if (window.GOOGLE_CLIENT_ID) s.setAttribute('data-client_id', window.GOOGLE_CLIENT_ID);
|
|
s.onload = function() { if (typeof onGisLoaded === 'function') onGisLoaded(); };
|
|
document.head.appendChild(s);
|
|
})();
|
|
</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
|
|
let clientId = GOOGLE_CLIENT_ID;
|
|
try {
|
|
if (API_URL) {
|
|
const res = await fetch(`${API_URL}/auth/config`);
|
|
const cfg = await res.json();
|
|
if (cfg.googleClientId) clientId = cfg.googleClientId;
|
|
}
|
|
} catch (e) {
|
|
console.warn("Could not load auth config:", e);
|
|
}
|
|
if (clientId) initGoogleSignIn(clientId);
|
|
});
|
|
|
|
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
|
|
const fb = document.getElementById("fallback-btn");
|
|
if (fb) fb.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>
|