- {
- signInWithPopup(auth, githubProvider);
- }}
- />
-
-
- >
- );
-};
-export default LoginButton;
diff --git a/src/authErrors.ts b/src/authErrors.ts
new file mode 100644
index 0000000..1b17c5b
--- /dev/null
+++ b/src/authErrors.ts
@@ -0,0 +1,50 @@
+/**
+ * Firebase auth error codes translated into something a user can act on.
+ *
+ * Sign-in failures used to vanish entirely — `signInWithPopup(...)` was called
+ * without a `catch`, so a closed popup, a blocked popup, a denied permission or a
+ * network failure all produced a screen that simply did nothing.
+ *
+ * Kept separate from src/authProviders.ts so hooks/useAuth.ts can use it without
+ * that module's icon imports entering the hook's runtime graph.
+ */
+const ERROR_MESSAGES: Record = {
+ "auth/popup-closed-by-user":
+ "The sign-in window was closed before it finished. Try again.",
+ "auth/cancelled-popup-request":
+ "Sign-in was cancelled. Close the previous prompt and try again.",
+ "auth/popup-blocked":
+ "Your browser blocked the sign-in popup. Allow popups for this site and try again.",
+ "auth/unauthorized-domain":
+ "This site's domain is not authorised in the Firebase console for that provider.",
+ "auth/account-exists-with-different-credential":
+ "That email is already registered with a different sign-in provider.",
+ "auth/invalid-credential":
+ "That sign-in was rejected. Check the provider account and try again.",
+ "auth/network-request-failed":
+ "Network error reaching the sign-in provider. Check your connection and try again.",
+ "auth/user-disabled": "This account has been disabled.",
+ "auth/too-many-requests": "Too many attempts. Wait a moment and try again.",
+};
+
+/**
+ * Turn an unknown sign-in failure into a user-facing sentence.
+ *
+ * Falls back to the Firebase message when the code is unrecognised: Firebase's own
+ * strings are developer-grade, but still better than swallowing the failure.
+ */
+export function describeSignInError(cause: unknown): string {
+ const wrapper = cause as { code?: unknown; message?: unknown } | null;
+ const code = wrapper?.code;
+ if (typeof code === "string" && ERROR_MESSAGES[code]) {
+ return ERROR_MESSAGES[code];
+ }
+ // Read `message` structurally rather than via `instanceof Error`: a thrown
+ // object literal (or a cross-realm FirebaseError) still carries a useful
+ // message, whereas String(cause) would collapse it to "[object Object]".
+ const detail =
+ typeof wrapper?.message === "string" && wrapper.message
+ ? wrapper.message
+ : String(cause);
+ return `Sign-in failed: ${detail}`;
+}
diff --git a/src/authProviders.ts b/src/authProviders.ts
new file mode 100644
index 0000000..1123d01
--- /dev/null
+++ b/src/authProviders.ts
@@ -0,0 +1,59 @@
+import type { ComponentType, CSSProperties } from "react";
+import {
+ FacebookAuthProvider,
+ GithubAuthProvider,
+ GoogleAuthProvider,
+ type AuthProvider,
+} from "firebase/auth";
+import {
+ FacebookFilled,
+ GithubFilled,
+ GoogleOutlined,
+} from "@ant-design/icons";
+
+/**
+ * A sign-in option rendered by the login screen.
+ *
+ * Adding a provider means appending ONE entry to `PROVIDERS` below. No component,
+ * no JSX, no layout change: the login panel maps over this array, so the markup is
+ * written once and never grows with the number of providers. Previously each
+ * provider had its own hand-written `
` block, which meant copy-pasting
+ * markup (and its three empty spacer columns) for every new provider.
+ */
+export interface ProviderConfig {
+ /** Stable identity for React keys and pending-state tracking. */
+ readonly key: string;
+ readonly label: string;
+ readonly Icon: ComponentType<{ style?: CSSProperties }>;
+ /**
+ * Build the Firebase provider at sign-in time.
+ *
+ * A factory, not an instance: the old module constructed all three
+ * `*AuthProvider`s at import time, so merely importing the login screen
+ * allocated Firebase objects. Keeping this lazy also gives each sign-in attempt a
+ * fresh provider, so a cancelled popup cannot leave stale scope/parameter state
+ * behind for the next attempt.
+ */
+ readonly create: () => AuthProvider;
+}
+
+export const PROVIDERS: readonly ProviderConfig[] = [
+ {
+ key: "google",
+ label: "Continue with Google",
+ Icon: GoogleOutlined,
+ create: () => new GoogleAuthProvider(),
+ },
+ {
+ key: "facebook",
+ label: "Continue with Facebook",
+ Icon: FacebookFilled,
+ create: () => new FacebookAuthProvider(),
+ },
+ {
+ key: "github",
+ label: "Continue with GitHub",
+ Icon: GithubFilled,
+ create: () => new GithubAuthProvider(),
+ },
+];
diff --git a/src/components/ApiDocs.tsx b/src/components/ApiDocs.tsx
new file mode 100644
index 0000000..cf044a2
--- /dev/null
+++ b/src/components/ApiDocs.tsx
@@ -0,0 +1,14 @@
+import SwaggerUI from "swagger-ui-react";
+import "swagger-ui-react/swagger-ui.css";
+import apiSpec from "../swagger_spec.json";
+
+/** The OpenAPI browser. The spec is generated at build time (see `npm run spec`). */
+const ApiDocs = () => (
+
+);
+
+export default ApiDocs;
diff --git a/src/components/AppFooter.tsx b/src/components/AppFooter.tsx
new file mode 100644
index 0000000..b3b571c
--- /dev/null
+++ b/src/components/AppFooter.tsx
@@ -0,0 +1,15 @@
+import { Layout } from "antd";
+import { appTag } from "../env.ts";
+
+const { Footer } = Layout;
+
+const AppFooter = () => (
+
+);
+
+export default AppFooter;
diff --git a/src/components/AppHeader.test.tsx b/src/components/AppHeader.test.tsx
new file mode 100644
index 0000000..1cf7c26
--- /dev/null
+++ b/src/components/AppHeader.test.tsx
@@ -0,0 +1,98 @@
+import { describe, expect, it, vi } from "vitest";
+import { page } from "vitest/browser";
+import { render } from "vitest-browser-react";
+import AppHeader from "./AppHeader.tsx";
+import "../index.css";
+import "../App.css";
+
+const menuItems = () =>
+ [
+ ...document.querySelectorAll(".app-menu .ant-menu-item"),
+ ] as HTMLLIElement[];
+
+const clickMenu = (label: string) => {
+ const item = menuItems().find((el) => el.textContent?.includes(label));
+ if (!item) throw new Error(`no menu item labelled ${label}`);
+ item.click();
+};
+
+describe("brand", () => {
+ it("renders the logo in the leading slot", async () => {
+ await render();
+ expect(document.querySelector(".app-logo svg")).toBeTruthy();
+ // Logo applies the theme hook to its inner (whose fill the paths
+ // inherit), not to the