mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 15:08:55 +00:00
fix(form): keep Google sign-in across page refresh
This commit is contained in:
parent
3efff5e784
commit
a43206de14
2 changed files with 189 additions and 19 deletions
|
|
@ -29,6 +29,7 @@ let countdownTimer = null;
|
||||||
let mealsListDelegationBound = false;
|
let mealsListDelegationBound = false;
|
||||||
|
|
||||||
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
|
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
|
||||||
|
const GOOGLE_SESSION_KEY = "seahaven.meals.googleIdToken";
|
||||||
|
|
||||||
window.googleCredential = null;
|
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() {
|
function initGoogleAuth() {
|
||||||
google.accounts.id.initialize({
|
google.accounts.id.initialize({
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
client_id: GOOGLE_CLIENT_ID,
|
||||||
|
|
@ -107,17 +170,19 @@ function initGoogleAuth() {
|
||||||
text: "signin_with",
|
text: "signin_with",
|
||||||
width: 300,
|
width: 300,
|
||||||
});
|
});
|
||||||
|
if (restoreGoogleSession()) return;
|
||||||
|
try {
|
||||||
|
google.accounts.id.prompt();
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCredentialResponse(response) {
|
function handleCredentialResponse(response) {
|
||||||
googleCredential = response.credential;
|
const token = response && response.credential;
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
if (!payload || !payload.email) return;
|
||||||
|
googleCredential = token;
|
||||||
syncGoogleCredential();
|
syncGoogleCredential();
|
||||||
const b64 = response.credential
|
persistGoogleSession(token);
|
||||||
.split(".")[1]
|
|
||||||
.replace(/-/g, "+")
|
|
||||||
.replace(/_/g, "/");
|
|
||||||
const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
||||||
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
||||||
googleUser = { name: payload.name, email: payload.email };
|
googleUser = { name: payload.name, email: payload.email };
|
||||||
|
|
||||||
document.getElementById("auth-overlay").classList.add("is-hidden");
|
document.getElementById("auth-overlay").classList.add("is-hidden");
|
||||||
|
|
@ -149,6 +214,7 @@ function signOut() {
|
||||||
googleCredential = null;
|
googleCredential = null;
|
||||||
googleUser = null;
|
googleUser = null;
|
||||||
syncGoogleCredential();
|
syncGoogleCredential();
|
||||||
|
clearGoogleSession();
|
||||||
if (
|
if (
|
||||||
CONFIG.authMode === "google" &&
|
CONFIG.authMode === "google" &&
|
||||||
typeof google !== "undefined" &&
|
typeof google !== "undefined" &&
|
||||||
|
|
|
||||||
|
|
@ -75,18 +75,20 @@ def _render_for_browser(*, google: bool = False) -> str:
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def _google_credential() -> str:
|
def _google_credential(extra: dict | None = None) -> str:
|
||||||
payload = json.dumps(
|
payload_obj = {
|
||||||
{
|
"name": "Test Admin",
|
||||||
"name": "Test Admin",
|
"email": "test-admin@example.com",
|
||||||
"email": "test-admin@example.com",
|
"picture": (
|
||||||
"picture": (
|
"data:image/svg+xml,"
|
||||||
"data:image/svg+xml,"
|
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
|
||||||
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
|
),
|
||||||
),
|
}
|
||||||
}
|
if extra:
|
||||||
).encode()
|
payload_obj.update(extra)
|
||||||
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
encoded = (
|
||||||
|
base64.urlsafe_b64encode(json.dumps(payload_obj).encode()).decode().rstrip("=")
|
||||||
|
)
|
||||||
return f"e30.{encoded}.signature"
|
return f"e30.{encoded}.signature"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -410,6 +412,7 @@ class TestGenerateFormPlaywright:
|
||||||
id: {
|
id: {
|
||||||
initialize() {},
|
initialize() {},
|
||||||
renderButton() {},
|
renderButton() {},
|
||||||
|
prompt() {},
|
||||||
disableAutoSelect() {},
|
disableAutoSelect() {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -933,6 +936,7 @@ class TestGenerateFormGooglePlaywright:
|
||||||
id: {
|
id: {
|
||||||
initialize() {},
|
initialize() {},
|
||||||
renderButton() {},
|
renderButton() {},
|
||||||
|
prompt() {},
|
||||||
disableAutoSelect() {},
|
disableAutoSelect() {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1007,6 +1011,105 @@ class TestGenerateFormGooglePlaywright:
|
||||||
finally:
|
finally:
|
||||||
browser.close()
|
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")
|
@pytest.fixture(scope="class")
|
||||||
@classmethod
|
@classmethod
|
||||||
def signed_in_admin_page(cls, tmp_path_factory):
|
def signed_in_admin_page(cls, tmp_path_factory):
|
||||||
|
|
@ -1036,6 +1139,7 @@ class TestGenerateFormGooglePlaywright:
|
||||||
id: {
|
id: {
|
||||||
initialize() {},
|
initialize() {},
|
||||||
renderButton() {},
|
renderButton() {},
|
||||||
|
prompt() {},
|
||||||
disableAutoSelect() {},
|
disableAutoSelect() {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue