Files
jobtrackingapp/job-tracker-ui/src/components/GoogleAuthCard.tsx
T

298 lines
11 KiB
TypeScript

import React, { useEffect, useRef, useState } from "react";
import { Box, Button, Chip, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import TwoFactorChallenge from "./TwoFactorChallenge";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
declare global {
interface Window {
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 = {
provider?: "local" | "google" | "external";
email?: string;
userName?: string;
displayName?: string;
firstName?: string;
lastName?: string;
googleLink?: {
linked: boolean;
email?: string | null;
linkedAt?: string | null;
} | null;
};
function loadGoogleScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (window.google?.accounts?.id) return resolve();
const existing = document.getElementById("google-gsi");
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
return;
}
const s = document.createElement("script");
s.id = "google-gsi";
s.src = "https://accounts.google.com/gsi/client";
s.async = true;
s.defer = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error("Failed to load Google script"));
document.head.appendChild(s);
});
}
export default function GoogleAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) {
const { toast } = useToast();
const { t, language } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [allowRegistration, setAllowRegistration] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
const signInOnly = presentation === "sign-in";
const signedIn = Boolean(me?.provider);
const actionLabel = signInOnly || !signedIn
? t("continueWithGoogle")
: me?.provider === "local" && !me?.googleLink?.linked
? t("linkWithGoogle")
: t("signInWithGoogle");
async function refreshMe() {
try {
const res = await api.get<MeResponse>("/auth/me");
setMe(res.data);
} catch {
setMe(null);
}
}
useEffect(() => {
if (signInOnly) return;
void refreshMe();
api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => {
setAllowRegistration(Boolean(res.data?.allowRegistration));
}).catch(() => setAllowRegistration(false));
}, [signInOnly]);
useEffect(() => {
if (signInOnly) return;
const onAuthChanged = () => { void refreshMe(); };
window.addEventListener("auth-changed", onAuthChanged);
return () => window.removeEventListener("auth-changed", onAuthChanged);
}, [signInOnly]);
useEffect(() => {
const host = hostRef.current;
if (!clientId || !host) return;
const shouldRenderButton = signInOnly || !signedIn || (me?.provider === "local" && !me?.googleLink?.linked);
host.replaceChildren();
if (!shouldRenderButton) return;
let active = true;
void loadGoogleScript()
.then(() => {
const identityApi = window.google?.accounts?.id;
if (!active || !identityApi || !hostRef.current) return;
hostRef.current.replaceChildren();
const credentialHandler = async (resp: GoogleCredentialResponse) => {
const credential = resp?.credential as string | undefined;
if (!credential) return;
setWorking(true);
try {
if (!signInOnly && me?.provider === "local") {
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
await refreshMe();
} else {
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 {
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}
}
} catch (e: unknown) {
toast(getApiErrorMessage(e, t("googleAuthFailed")), "error");
} finally {
setWorking(false);
}
};
activeCredentialHandler = credentialHandler;
initializeGoogleIdentity(identityApi, clientId);
identityApi.renderButton(hostRef.current, {
theme: "outline",
size: "large",
type: "standard",
shape: "pill",
text: !signInOnly && me?.provider === "local" ? "continue_with" : "signin_with",
});
})
.catch(() => toast(t("googleScriptLoadFailed"), "error"));
return () => {
active = false;
activeCredentialHandler = null;
host.replaceChildren();
};
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signInOnly, signedIn, toast, t]);
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
if (signInOnly) {
if (pendingToken) {
return <TwoFactorChallenge
pendingToken={pendingToken}
onCancel={() => setPendingToken(null)}
onSuccess={() => {
setPendingToken(null);
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}}
/>;
}
return clientId
? <Box role="group" aria-label={actionLabel} aria-busy={working} sx={{ display: "flex", justifyContent: "center", minHeight: 40 }}><div ref={hostRef} /></Box>
: <Button fullWidth variant="outlined" disabled>{actionLabel}</Button>;
}
return (
<PageSection title={t("googleAccountTitle")} surface>
{!clientId && (
<Typography sx={{ color: "text.secondary" }}>
{t("googleSetupHint")}
</Typography>
)}
{clientId && pendingToken && (
<TwoFactorChallenge
pendingToken={pendingToken}
onCancel={() => setPendingToken(null)}
onSuccess={() => {
setPendingToken(null);
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}}
/>
)}
{clientId && !pendingToken && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={me?.googleLink?.linked ? t("googleLinked") : t("googleAvailableToLink")} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} /> : null}
</Box>
{!signedIn ? (
<Typography sx={{ color: "text.secondary" }}>
{allowRegistration ? t("googleSignInHintSelfServe") : t("googleSignInHint")}
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
{me.googleLink?.linked
? t("googleLinkedTo", { email: me.googleLink.email || t("googleLinkedToYourAccount") })
: t("googleBindHint")}
</Typography>
) : (
<Typography sx={{ color: "text.secondary" }}>
{t("googleExchangeHint")}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 1 }}>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
{actionLabel}
</Typography>
<div ref={hostRef} />
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
{signedIn ? (
<Button
variant="outlined"
onClick={() => {
void api.post("/auth/logout").catch(() => undefined).finally(() => {
clearAuthClientState();
setMe(null);
toast(t("signedOut"), "info");
});
}}
>
{t("signOut")}
</Button>
) : null}
{me?.provider === "local" && me.googleLink?.linked ? (
<Button
variant="outlined"
color="warning"
disabled={working}
onClick={async () => {
try {
await api.delete("/auth/google/link");
toast(t("googleUnlinked"), "info");
await refreshMe();
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("googleUnlinkFailed");
toast(String(msg), "error");
}
}}
>
{t("unlinkGoogle")}
</Button>
) : null}
</Box>
{signedIn && me?.email ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("signedInAs", { name: signedInName })}
</Typography>
) : null}
</Box>
)}
</PageSection>
);
}