Polish UI, harden company creation, and add error pages

This commit is contained in:
cesnimda
2026-03-23 19:34:29 +01:00
parent 8f5eab2fe4
commit fcafda6f52
38 changed files with 2293 additions and 1269 deletions
@@ -5,6 +5,7 @@ import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
declare global {
interface Window {
@@ -46,6 +47,7 @@ function loadGoogleScript(): Promise<void> {
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);
@@ -81,20 +83,20 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
if (cancelled) return;
setAuthToken(res.data.accessToken);
setToken(res.data.accessToken);
toast("Signed in with Google.", "success");
toast(t("googleSignedIn"), "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");
toast(t("googleNotLinkedYet"), "info");
}
};
void exchange();
return () => {
cancelled = true;
};
}, [token, isRawGoogleToken, onSignedIn, toast]);
}, [token, isRawGoogleToken, onSignedIn, toast, t]);
useEffect(() => {
const host = hostRef.current;
@@ -102,9 +104,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
host.replaceChildren();
if (!shouldRenderButton) {
return;
}
if (!shouldRenderButton) return;
let active = true;
void loadGoogleScript()
@@ -120,17 +120,17 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
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");
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);
setToken(res.data.accessToken);
toast("Signed in with Google.", "success");
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Google authentication failed.";
const msg = e?.response?.data || e?.message || t("googleAuthFailed");
toast(String(msg), "error");
} finally {
setWorking(false);
@@ -145,46 +145,48 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
text: me?.provider === "local" ? "continue_with" : "signin_with",
});
})
.catch(() => toast("Google auth script failed to load.", "error"));
.catch(() => toast(t("googleScriptLoadFailed"), "error"));
return () => {
active = false;
host.replaceChildren();
};
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast]);
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]);
const signedInName = me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Google account
{t("googleAccountTitle")}
</Typography>
{!clientId && (
<Typography sx={{ color: "text.secondary" }}>
Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.
{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 ? "Linked" : "Available to link"} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={`Linked ${new Date(me.googleLink.linkedAt).toLocaleDateString()}`} /> : null}
<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" }}>
Sign in with a Google account that has already been linked to your Job Tracker user.
{t("googleSignInHint")}
</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."}
? t("googleLinkedTo", { email: me.googleLink.email || t("googleLinkedToYourAccount") })
: t("googleBindHint")}
</Typography>
) : (
<Typography sx={{ color: "text.secondary" }}>
Exchange your Google sign-in for a normal Job Tracker session.
{t("googleExchangeHint")}
</Typography>
)}
@@ -198,10 +200,10 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
clearAuthToken();
setToken(null);
setMe(null);
toast("Signed out.", "info");
toast(t("signedOut"), "info");
}}
>
Sign out
{t("signOut")}
</Button>
) : null}
@@ -213,22 +215,22 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
onClick={async () => {
try {
await api.delete("/auth/google/link");
toast("Google account unlinked.", "info");
toast(t("googleUnlinked"), "info");
await refreshMe();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to unlink Google account.";
const msg = e?.response?.data || e?.message || t("googleUnlinkFailed");
toast(String(msg), "error");
}
}}
>
Unlink Google
{t("unlinkGoogle")}
</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}.
{t("signedInAs", { name: signedInName })}
</Typography>
) : null}
</Box>