fix(auth): initialize Google Identity once
This commit is contained in:
@@ -240,3 +240,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
|
|||||||
| V-206 | Ownership/migration chain; populated adoption/downgrade/retry; practice-state/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move durable interview-preparation items to migration ownership without losing user or AI preparation content | PASS — focused ownership/migration 18/18; user-authored and AI-generated questions, answers, prepared state and source metadata survive adoption, downgrade and re-upgrade; owner/job/sort index exists; deleting the parent application cascades through preparation items; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 734/734 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Fifteen reconciler-owned tables remain | JT-019 feature-table transfers complete; the Career Profile aggregate and Identity group remain dependency-aware batches |
|
| V-206 | Ownership/migration chain; populated adoption/downgrade/retry; practice-state/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move durable interview-preparation items to migration ownership without losing user or AI preparation content | PASS — focused ownership/migration 18/18; user-authored and AI-generated questions, answers, prepared state and source metadata survive adoption, downgrade and re-upgrade; owner/job/sort index exists; deleting the parent application cascades through preparation items; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 734/734 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Fifteen reconciler-owned tables remain | JT-019 feature-table transfers complete; the Career Profile aggregate and Identity group remain dependency-aware batches |
|
||||||
| V-207 | Ownership/migration chain; populated aggregate adoption/downgrade/retry; JSON/text/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the canonical Career Profile, append-only history, and six relational child types to migration ownership without losing career data | PASS — focused ownership/migration 19/19; canonical and long-tail JSON, Norwegian text, version history, experience, education, skill, project, certification and language rows survive adoption, downgrade and re-upgrade; all 14 aggregate indexes exist; deleting the profile cascades through history and children; generated MariaDB SQL uses bounded indexed keys and provider-safe types; startup creates none of the eight tables; full backend 735/735 | MariaDB SQL generated only; no production migration. Guarded historical LongTailJson/index/auto-increment repairs remain temporarily. Seven reconciler-owned Identity tables remain | JT-019 feature and career transfers complete; isolate the Identity ownership boundary next |
|
| V-207 | Ownership/migration chain; populated aggregate adoption/downgrade/retry; JSON/text/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the canonical Career Profile, append-only history, and six relational child types to migration ownership without losing career data | PASS — focused ownership/migration 19/19; canonical and long-tail JSON, Norwegian text, version history, experience, education, skill, project, certification and language rows survive adoption, downgrade and re-upgrade; all 14 aggregate indexes exist; deleting the profile cascades through history and children; generated MariaDB SQL uses bounded indexed keys and provider-safe types; startup creates none of the eight tables; full backend 735/735 | MariaDB SQL generated only; no production migration. Guarded historical LongTailJson/index/auto-increment repairs remain temporarily. Seven reconciler-owned Identity tables remain | JT-019 feature and career transfers complete; isolate the Identity ownership boundary next |
|
||||||
| V-208 | Ownership/migration chain; populated Identity adoption/downgrade/retry; credential/preference/FK/index assertions; MariaDB script; full backend; fresh application startup | Repository root / disposable local SQLite | Complete JT-019 by moving the seven ASP.NET Identity tables to migration ownership without invalidating authentication data | PASS — focused ownership/migration 20/20; password hash, Bokmål preference, 2FA state, role assignment, role/user claims, external login and token survive adoption, downgrade and re-upgrade; all eight Identity indexes exist; user and role deletion cascades remain effective; generated MariaDB SQL is provider-safe; startup creates no model tables; full backend 736/736; a fresh application applies the complete chain, reaches Identity role initialization and listens normally | MariaDB SQL generated only; no production migration. The historical AspNetUsers compatibility bootstrap and guarded column/index repairs remain for chain traversal and legacy upgrades. Launch settings supplied an existing policy-invalid development admin password, producing a non-fatal seed warning | JT-019 complete; future work may retire individual repair statements only after provider-backed historical fixtures prove them redundant |
|
| V-208 | Ownership/migration chain; populated Identity adoption/downgrade/retry; credential/preference/FK/index assertions; MariaDB script; full backend; fresh application startup | Repository root / disposable local SQLite | Complete JT-019 by moving the seven ASP.NET Identity tables to migration ownership without invalidating authentication data | PASS — focused ownership/migration 20/20; password hash, Bokmål preference, 2FA state, role assignment, role/user claims, external login and token survive adoption, downgrade and re-upgrade; all eight Identity indexes exist; user and role deletion cascades remain effective; generated MariaDB SQL is provider-safe; startup creates no model tables; full backend 736/736; a fresh application applies the complete chain, reaches Identity role initialization and listens normally | MariaDB SQL generated only; no production migration. The historical AspNetUsers compatibility bootstrap and guarded column/index repairs remain for chain traversal and legacy upgrades. Launch settings supplied an existing policy-invalid development admin password, producing a non-fatal seed warning | JT-019 complete; future work may retire individual repair statements only after provider-backed historical fixtures prove them redundant |
|
||||||
|
| V-209 | Google Identity initialization regression; focused login Jest; ESLint; TypeScript; complete Playwright | `job-tracker-ui` / disposable local application | Remove repeated global Google SDK initialization without changing sign-in or account-link behavior | PASS — SDK initialization is stable per loaded Identity API/client ID while the mounted card owns the current credential handler; login 13/13, lint and TypeScript pass; Playwright 10/10 covers authentication, job creation/workspace, Career Workspace and searchable PDF exports with no repeated-initialize warning | Synthetic Google callback only; no real provider credential, account link or production call | Repeatable browser-console defect closed; real-provider verification remains externally gated |
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ Updated: 2026-08-30
|
|||||||
- Next production build and TypeScript: passed.
|
- Next production build and TypeScript: passed.
|
||||||
- Full backend: 736/736 tests passed after completing the Identity/JT-019 ownership transfer.
|
- Full backend: 736/736 tests passed after completing the Identity/JT-019 ownership transfer.
|
||||||
- Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors.
|
- Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors.
|
||||||
- Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate.
|
- Playwright: final complete browser rerun passed 10/10, including authenticated job creation/workspace flows and both public and long searchable multi-page CV PDF exports.
|
||||||
- Focused backend match/intelligence verification: 34/34 passed.
|
- Focused backend match/intelligence verification: 34/34 passed.
|
||||||
- Focused frontend application-intelligence verification: 11/11 passed.
|
- Focused frontend application-intelligence verification: 11/11 passed.
|
||||||
- Career/Profile focused verification: 2 suites, 19/19 passed, including the final navigation/state-preservation regression; final full-suite/E2E gates remain pending.
|
- Career/Profile focused verification: 2 suites, 19/19 passed, including the final navigation/state-preservation regression; final full-suite/E2E gates remain pending.
|
||||||
@@ -117,7 +117,7 @@ Updated: 2026-08-30
|
|||||||
- Shared view-state focused verification: 3 suites and 15/15 tests passed, including Bokmål loading/retry and Kanban interaction coverage; TypeScript passed.
|
- Shared view-state focused verification: 3 suites and 15/15 tests passed, including Bokmål loading/retry and Kanban interaction coverage; TypeScript passed.
|
||||||
- Admin Audit/settings/profile focused verification: 3 suites and 17/17 tests passed, including unavailable-versus-empty recovery and Bokmål audit actions that preserve stored event content; TypeScript passed.
|
- Admin Audit/settings/profile focused verification: 3 suites and 17/17 tests passed, including unavailable-versus-empty recovery and Bokmål audit actions that preserve stored event content; TypeScript passed.
|
||||||
- Turnstile/auth recovery focused verification: 4 suites and 18/18 tests passed; the provider widget receives the Bokmål language code and TypeScript passed.
|
- Turnstile/auth recovery focused verification: 4 suites and 18/18 tests passed; the provider widget receives the Bokmål language code and TypeScript passed.
|
||||||
- Final shared-state/localization regression: all 64 frontend suites and 272/272 tests passed; optimized Next build and integrated TypeScript passed; complete Playwright passed 10/10, including public PDF and the searchable multi-page Code template. Only the pre-existing Google Identity development logger warning remained.
|
- Final shared-state/localization regression: all 64 frontend suites and 272/272 tests passed; optimized Next build and integrated TypeScript passed; complete Playwright passed 10/10, including public PDF and the searchable multi-page Code template. Google Identity now initializes its global SDK once while rerenders replace only the active credential handler; the focused login regression, ESLint, TypeScript and a fresh 10/10 browser run pass without the repeated-initialize warning.
|
||||||
- Admin/correspondence/profile helper-copy verification: 3 suites and 22/22 tests passed, including a Bokmål SMTP-message regression; TypeScript passed.
|
- Admin/correspondence/profile helper-copy verification: 3 suites and 22/22 tests passed, including a Bokmål SMTP-message regression; TypeScript passed.
|
||||||
- Local toolchain/lint verification: Python 3.12.10; sidecar 26/26 tests passed with five existing SWIG deprecation warnings; ESLint passes with zero warnings; frontend 64 suites and 272/272 tests passed; optimized Next build and integrated TypeScript passed; npm audit reports zero vulnerabilities.
|
- Local toolchain/lint verification: Python 3.12.10; sidecar 26/26 tests passed with five existing SWIG deprecation warnings; ESLint passes with zero warnings; frontend 64 suites and 272/272 tests passed; optimized Next build and integrated TypeScript passed; npm audit reports zero vulnerabilities.
|
||||||
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
|
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
|
||||||
|
|||||||
@@ -10,10 +10,38 @@ import { useI18n } from "../i18n/I18nProvider";
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
google?: any;
|
google?: {
|
||||||
|
accounts?: {
|
||||||
|
id?: GoogleIdentityApi;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GoogleCredentialResponse = {
|
||||||
|
credential?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GoogleIdentityApi = {
|
||||||
|
initialize: (options: { client_id: string; callback: (response: GoogleCredentialResponse) => void }) => void;
|
||||||
|
renderButton: (host: HTMLElement, options: Record<string, string>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let initializedIdentityApi: GoogleIdentityApi | null = null;
|
||||||
|
let initializedClientId = "";
|
||||||
|
let activeCredentialHandler: ((response: GoogleCredentialResponse) => void) | null = null;
|
||||||
|
|
||||||
|
function initializeGoogleIdentity(identityApi: GoogleIdentityApi, clientId: string) {
|
||||||
|
if (initializedIdentityApi === identityApi && initializedClientId === clientId) return;
|
||||||
|
|
||||||
|
identityApi.initialize({
|
||||||
|
client_id: clientId,
|
||||||
|
callback: (response) => activeCredentialHandler?.(response),
|
||||||
|
});
|
||||||
|
initializedIdentityApi = identityApi;
|
||||||
|
initializedClientId = clientId;
|
||||||
|
}
|
||||||
|
|
||||||
type MeResponse = {
|
type MeResponse = {
|
||||||
provider?: "local" | "google" | "external";
|
provider?: "local" | "google" | "external";
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -100,37 +128,37 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
|
|||||||
let active = true;
|
let active = true;
|
||||||
void loadGoogleScript()
|
void loadGoogleScript()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!active || !window.google?.accounts?.id || !hostRef.current) return;
|
const identityApi = window.google?.accounts?.id;
|
||||||
|
if (!active || !identityApi || !hostRef.current) return;
|
||||||
hostRef.current.replaceChildren();
|
hostRef.current.replaceChildren();
|
||||||
window.google.accounts.id.initialize({
|
const credentialHandler = async (resp: GoogleCredentialResponse) => {
|
||||||
client_id: clientId,
|
const credential = resp?.credential as string | undefined;
|
||||||
callback: async (resp: any) => {
|
if (!credential) return;
|
||||||
const credential = resp?.credential as string | undefined;
|
setWorking(true);
|
||||||
if (!credential) return;
|
try {
|
||||||
setWorking(true);
|
if (!signInOnly && me?.provider === "local") {
|
||||||
try {
|
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
|
||||||
if (!signInOnly && me?.provider === "local") {
|
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
|
||||||
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
|
await refreshMe();
|
||||||
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
|
} else {
|
||||||
await refreshMe();
|
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
|
||||||
|
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
|
||||||
|
setPendingToken(res.data.pendingToken);
|
||||||
} else {
|
} else {
|
||||||
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
|
window.dispatchEvent(new Event("auth-changed"));
|
||||||
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
|
toast(t("googleSignedIn"), "success");
|
||||||
setPendingToken(res.data.pendingToken);
|
onSignedIn?.();
|
||||||
} else {
|
|
||||||
window.dispatchEvent(new Event("auth-changed"));
|
|
||||||
toast(t("googleSignedIn"), "success");
|
|
||||||
onSignedIn?.();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
|
||||||
toast(getApiErrorMessage(e, t("googleAuthFailed")), "error");
|
|
||||||
} finally {
|
|
||||||
setWorking(false);
|
|
||||||
}
|
}
|
||||||
},
|
} catch (e: unknown) {
|
||||||
});
|
toast(getApiErrorMessage(e, t("googleAuthFailed")), "error");
|
||||||
window.google.accounts.id.renderButton(hostRef.current, {
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
activeCredentialHandler = credentialHandler;
|
||||||
|
initializeGoogleIdentity(identityApi, clientId);
|
||||||
|
identityApi.renderButton(hostRef.current, {
|
||||||
theme: "outline",
|
theme: "outline",
|
||||||
size: "large",
|
size: "large",
|
||||||
type: "standard",
|
type: "standard",
|
||||||
@@ -142,6 +170,7 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
activeCredentialHandler = null;
|
||||||
host.replaceChildren();
|
host.replaceChildren();
|
||||||
};
|
};
|
||||||
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signInOnly, signedIn, toast, t]);
|
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signInOnly, signedIn, toast, t]);
|
||||||
|
|||||||
@@ -155,8 +155,9 @@ describe('LoginPage', () => {
|
|||||||
it('completes a Google credential return without account-linking copy', async () => {
|
it('completes a Google credential return without account-linking copy', async () => {
|
||||||
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = 'google-client';
|
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = 'google-client';
|
||||||
let callback: ((response: { credential: string }) => void) | undefined;
|
let callback: ((response: { credential: string }) => void) | undefined;
|
||||||
|
const initialize = jest.fn((options: any) => { callback = options.callback; });
|
||||||
(window as any).google = { accounts: { id: {
|
(window as any).google = { accounts: { id: {
|
||||||
initialize: jest.fn((options: any) => { callback = options.callback; }),
|
initialize,
|
||||||
renderButton: jest.fn((host: HTMLElement) => {
|
renderButton: jest.fn((host: HTMLElement) => {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.textContent = 'Continue with Google';
|
button.textContent = 'Continue with Google';
|
||||||
@@ -172,6 +173,7 @@ describe('LoginPage', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/google/exchange', { token: 'synthetic-google-token', rememberMe: true }));
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/google/exchange', { token: 'synthetic-google-token', rememberMe: true }));
|
||||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
|
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
|
||||||
|
expect(initialize).toHaveBeenCalledTimes(1);
|
||||||
expect(screen.queryByText(/create your account automatically/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/create your account automatically/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user