252 lines
8.7 KiB
TypeScript
252 lines
8.7 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import { clearAuthToken, decodeJwtPayload, getAuthPersistencePreference, getAuthToken, setAuthToken } from "../auth";
|
|
import { useToast } from "../toast";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
|
|
declare global {
|
|
interface Window {
|
|
google?: any;
|
|
}
|
|
}
|
|
|
|
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 }: { onSignedIn?: () => void }) {
|
|
const { toast } = useToast();
|
|
const { t } = useI18n();
|
|
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
|
const [me, setMe] = useState<MeResponse | null>(null);
|
|
const [working, setWorking] = useState(false);
|
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
|
|
const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
|
|
const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
|
|
|
|
const actionLabel = !token
|
|
? t("continueWithGoogle")
|
|
: me?.provider === "local" && !me?.googleLink?.linked
|
|
? t("linkWithGoogle")
|
|
: t("signInWithGoogle");
|
|
|
|
async function refreshMe() {
|
|
if (!getAuthToken()) {
|
|
setMe(null);
|
|
return;
|
|
}
|
|
try {
|
|
const res = await api.get<MeResponse>("/auth/me");
|
|
setMe(res.data);
|
|
} catch {
|
|
setMe(null);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void refreshMe();
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
if (!token || !isRawGoogleToken) return;
|
|
let cancelled = false;
|
|
const exchange = async () => {
|
|
try {
|
|
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
|
|
if (cancelled) return;
|
|
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
|
|
setToken(res.data.accessToken);
|
|
toast(t("googleSignedIn"), "success");
|
|
onSignedIn?.();
|
|
} catch {
|
|
if (cancelled) return;
|
|
clearAuthToken();
|
|
setToken(null);
|
|
toast(t("googleNotLinkedYet"), "info");
|
|
}
|
|
};
|
|
void exchange();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [token, isRawGoogleToken, onSignedIn, toast, t]);
|
|
|
|
useEffect(() => {
|
|
const host = hostRef.current;
|
|
if (!clientId || !host) return;
|
|
|
|
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
|
|
host.replaceChildren();
|
|
if (!shouldRenderButton) return;
|
|
|
|
let active = true;
|
|
void loadGoogleScript()
|
|
.then(() => {
|
|
if (!active || !window.google?.accounts?.id || !hostRef.current) return;
|
|
hostRef.current.replaceChildren();
|
|
window.google.accounts.id.initialize({
|
|
client_id: clientId,
|
|
callback: async (resp: any) => {
|
|
const credential = resp?.credential as string | undefined;
|
|
if (!credential) return;
|
|
setWorking(true);
|
|
try {
|
|
if (me?.provider === "local") {
|
|
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential });
|
|
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
|
|
await refreshMe();
|
|
} else {
|
|
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
|
|
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
|
|
setToken(res.data.accessToken);
|
|
toast(t("googleSignedIn"), "success");
|
|
onSignedIn?.();
|
|
}
|
|
} catch (e: any) {
|
|
toast(getApiErrorMessage(e, t("googleAuthFailed")), "error");
|
|
} finally {
|
|
setWorking(false);
|
|
}
|
|
},
|
|
});
|
|
window.google.accounts.id.renderButton(hostRef.current, {
|
|
theme: "outline",
|
|
size: "large",
|
|
type: "standard",
|
|
shape: "pill",
|
|
text: me?.provider === "local" ? "continue_with" : "signin_with",
|
|
});
|
|
})
|
|
.catch(() => toast(t("googleScriptLoadFailed"), "error"));
|
|
|
|
return () => {
|
|
active = false;
|
|
host.replaceChildren();
|
|
};
|
|
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]);
|
|
|
|
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
|
|
|
|
return (
|
|
<Paper sx={{ mt: 2, p: 2 }}>
|
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
|
{t("googleAccountTitle")}
|
|
</Typography>
|
|
|
|
{!clientId && (
|
|
<Typography sx={{ color: "text.secondary" }}>
|
|
{t("googleSetupHint")}
|
|
</Typography>
|
|
)}
|
|
|
|
{clientId && (
|
|
<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() })} /> : null}
|
|
</Box>
|
|
|
|
{!token ? (
|
|
<Typography sx={{ color: "text.secondary" }}>
|
|
{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" }}>
|
|
{token ? (
|
|
<Button
|
|
variant="outlined"
|
|
onClick={() => {
|
|
clearAuthToken();
|
|
setToken(null);
|
|
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>
|
|
|
|
{token && me?.email ? (
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{t("signedInAs", { name: signedInName })}
|
|
</Typography>
|
|
) : null}
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|