Add full profiles and latency tests

This commit is contained in:
cesnimda
2026-03-22 12:06:25 +01:00
parent 91f6361055
commit 0fa481cab6
11 changed files with 704 additions and 103 deletions
+144 -27
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
import { useToast } from "../toast";
@@ -11,6 +12,19 @@ declare global {
}
}
type MeResponse = {
provider?: "local" | "google" | "external";
email?: 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();
@@ -33,74 +47,177 @@ function loadGoogleScript(): Promise<void> {
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
const { toast } = useToast();
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 email = payload?.email as string | undefined;
const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
async function refreshMe() {
if (!getAuthToken()) {
setMe(null);
return;
}
try {
const res = await api.get<MeResponse>("/auth/me");
setMe(res.data);
} catch {
setMe(null);
}
}
useEffect(() => {
if (!clientId) return;
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);
setToken(res.data.accessToken);
toast("Signed in with Google.", "success");
onSignedIn?.();
} catch {
if (cancelled) return;
clearAuthToken();
setToken(null);
toast("This Google account is not linked yet. Sign in locally first to bind it.", "info");
}
};
void exchange();
return () => {
cancelled = true;
};
}, [token, isRawGoogleToken, onSignedIn, toast]);
useEffect(() => {
if (!clientId || !hostRef.current) return;
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
if (!shouldRenderButton) {
hostRef.current.innerHTML = "";
return;
}
void loadGoogleScript()
.then(() => {
if (!window.google?.accounts?.id) return;
if (!window.google?.accounts?.id || !hostRef.current) return;
hostRef.current.innerHTML = "";
window.google.accounts.id.initialize({
client_id: clientId,
callback: (resp: any) => {
if (resp?.credential) {
setAuthToken(resp.credential);
setToken(resp.credential);
toast("Signed in.", "success");
onSignedIn?.();
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 ? `Linked Google account ${res.data.email}.` : "Google account linked.", "success");
await refreshMe();
} else {
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
setAuthToken(res.data.accessToken);
setToken(res.data.accessToken);
toast("Signed in with Google.", "success");
onSignedIn?.();
}
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Google authentication failed.";
toast(String(msg), "error");
} finally {
setWorking(false);
}
},
});
window.google.accounts.id.renderButton(document.getElementById("gsi-btn"), {
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("Google auth script failed to load.", "error"));
}, [clientId, onSignedIn, toast]);
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast]);
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Authentication (Google)
Google account
</Typography>
{!clientId && (
<Typography sx={{ color: "text.secondary" }}>
Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable sign-in.
Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.
</Typography>
)}
{clientId && (
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<div id="gsi-btn" />
{token ? (
<>
<Typography sx={{ color: "text.secondary" }}>
Signed in{email ? ` as ${email}` : ""}.
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
{!token ? (
<Typography sx={{ color: "text.secondary" }}>
Sign in with a Google account that has already been linked to your Job Tracker user.
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
{me.googleLink?.linked
? `Linked to ${me.googleLink.email || "your Google account"}.`
: "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data."}
</Typography>
) : (
<Typography sx={{ color: "text.secondary" }}>
Exchange your Google sign-in for a normal Job Tracker session.
</Typography>
)}
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<div ref={hostRef} />
{token ? (
<Button
variant="outlined"
onClick={() => {
clearAuthToken();
setToken(null);
setMe(null);
toast("Signed out.", "info");
}}
>
Sign out
</Button>
</>
) : (
<Typography sx={{ color: "text.secondary" }}>
Sign in to unlock API access.
) : null}
{me?.provider === "local" && me.googleLink?.linked ? (
<Button
variant="outlined"
color="warning"
disabled={working}
onClick={async () => {
try {
await api.delete("/auth/google/link");
toast("Google account unlinked.", "info");
await refreshMe();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to unlink Google account.";
toast(String(msg), "error");
}
}}
>
Unlink Google
</Button>
) : null}
</Box>
{token && me?.email ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Signed in as {me.displayName || [me.firstName, me.lastName].filter(Boolean).join(" ") || me.email}.
</Typography>
)}
) : null}
</Box>
)}
</Paper>