fix(form): keep Google sign-in across page refresh

This commit is contained in:
Adam Moussa 2026-09-21 18:39:16 -04:00
parent 3efff5e784
commit a43206de14
No known key found for this signature in database
2 changed files with 189 additions and 19 deletions

View file

@ -29,6 +29,7 @@ let countdownTimer = null;
let mealsListDelegationBound = false;
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
const GOOGLE_SESSION_KEY = "seahaven.meals.googleIdToken";
window.googleCredential = null;
@ -95,6 +96,68 @@ function waitForGoogleAuth(attempt = 0) {
}
}
function decodeJwtPayload(token) {
if (!token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const json = new TextDecoder().decode(
Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)),
);
return JSON.parse(json);
} catch (e) {
return null;
}
}
function googleTokenIsUsable(token) {
const payload = decodeJwtPayload(token);
if (!payload || !payload.email) return false;
if (typeof payload.exp === "number" && payload.exp * 1000 <= Date.now()) {
return false;
}
if (GOOGLE_CLIENT_ID && payload.aud && payload.aud !== GOOGLE_CLIENT_ID) {
return false;
}
return true;
}
function readStoredGoogleToken() {
try {
return sessionStorage.getItem(GOOGLE_SESSION_KEY);
} catch (e) {
return null;
}
}
function persistGoogleSession(token) {
try {
if (googleTokenIsUsable(token)) {
sessionStorage.setItem(GOOGLE_SESSION_KEY, token);
} else {
sessionStorage.removeItem(GOOGLE_SESSION_KEY);
}
} catch (e) {}
}
function clearGoogleSession() {
try {
sessionStorage.removeItem(GOOGLE_SESSION_KEY);
} catch (e) {}
}
function restoreGoogleSession() {
const token = readStoredGoogleToken();
if (!googleTokenIsUsable(token)) {
if (token) clearGoogleSession();
return false;
}
handleCredentialResponse({ credential: token });
return true;
}
function initGoogleAuth() {
google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
@ -107,17 +170,19 @@ function initGoogleAuth() {
text: "signin_with",
width: 300,
});
if (restoreGoogleSession()) return;
try {
google.accounts.id.prompt();
} catch (e) {}
}
function handleCredentialResponse(response) {
googleCredential = response.credential;
const token = response && response.credential;
const payload = decodeJwtPayload(token);
if (!payload || !payload.email) return;
googleCredential = token;
syncGoogleCredential();
const b64 = response.credential
.split(".")[1]
.replace(/-/g, "+")
.replace(/_/g, "/");
const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
persistGoogleSession(token);
googleUser = { name: payload.name, email: payload.email };
document.getElementById("auth-overlay").classList.add("is-hidden");
@ -149,6 +214,7 @@ function signOut() {
googleCredential = null;
googleUser = null;
syncGoogleCredential();
clearGoogleSession();
if (
CONFIG.authMode === "google" &&
typeof google !== "undefined" &&

View file

@ -75,18 +75,20 @@ def _render_for_browser(*, google: bool = False) -> str:
return html
def _google_credential() -> str:
payload = json.dumps(
{
"name": "Test Admin",
"email": "test-admin@example.com",
"picture": (
"data:image/svg+xml,"
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
),
}
).encode()
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
def _google_credential(extra: dict | None = None) -> str:
payload_obj = {
"name": "Test Admin",
"email": "test-admin@example.com",
"picture": (
"data:image/svg+xml,"
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
),
}
if extra:
payload_obj.update(extra)
encoded = (
base64.urlsafe_b64encode(json.dumps(payload_obj).encode()).decode().rstrip("=")
)
return f"e30.{encoded}.signature"
@ -410,6 +412,7 @@ class TestGenerateFormPlaywright:
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
@ -933,6 +936,7 @@ class TestGenerateFormGooglePlaywright:
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
@ -1007,6 +1011,105 @@ class TestGenerateFormGooglePlaywright:
finally:
browser.close()
def test_refresh_restores_google_session(self, tmp_path):
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
out = tmp_path / "google-session-form.html"
out.write_text(_render_for_browser(google=True))
credential = _google_credential({"exp": 2_000_000_000})
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
except Exception as exc:
raise RuntimeError(
"Chromium is required for form Playwright tests. "
"Run: playwright install --with-deps chromium"
) from exc
try:
page = browser.new_page()
page.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
_mock_form_routes(page)
page.goto(out.as_uri(), wait_until="domcontentloaded")
page.evaluate(
"(token) => handleCredentialResponse({ credential: token })",
credential,
)
page.wait_for_function(
"""() => {
const app = document.getElementById('app');
return app && !app.classList.contains('is-hidden');
}"""
)
assert page.evaluate(
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const app = document.getElementById('app');
const overlay = document.getElementById('auth-overlay');
const email = document.getElementById('user-email');
return app && !app.classList.contains('is-hidden')
&& overlay && overlay.classList.contains('is-hidden')
&& email && email.textContent === 'test-admin@example.com';
}"""
)
page.evaluate("signOut()")
assert (
page.evaluate(
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
)
is None
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const overlay = document.getElementById('auth-overlay');
const app = document.getElementById('app');
return overlay && !overlay.classList.contains('is-hidden')
&& app && app.classList.contains('is-hidden');
}"""
)
page.evaluate(
"(token) => sessionStorage.setItem('seahaven.meals.googleIdToken', token)",
_google_credential({"exp": 1}),
)
page.reload(wait_until="domcontentloaded")
_mock_form_routes(page)
page.wait_for_function(
"""() => {
const overlay = document.getElementById('auth-overlay');
const app = document.getElementById('app');
return overlay && !overlay.classList.contains('is-hidden')
&& app && app.classList.contains('is-hidden')
&& !sessionStorage.getItem('seahaven.meals.googleIdToken');
}"""
)
finally:
browser.close()
@pytest.fixture(scope="class")
@classmethod
def signed_in_admin_page(cls, tmp_path_factory):
@ -1036,6 +1139,7 @@ class TestGenerateFormGooglePlaywright:
id: {
initialize() {},
renderButton() {},
prompt() {},
disableAutoSelect() {},
},
},