mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 04:13:12 +00:00
fix(form): keep Google sign-in across page refresh (#204)
This commit is contained in:
parent
3efff5e784
commit
596e949eef
2 changed files with 189 additions and 19 deletions
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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() {},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue