Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions apps/api/src/components/modules/common/session-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Profile } from "@gov-portal/api-client";
import { GithubLogoIcon, ShieldCheckIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -40,6 +41,7 @@ export function SessionMenu({
greetingLabel,
profileLabel,
adminLabel,
retryLabel,
}: {
locale: Locale;
signInLabel: string;
Expand All @@ -48,17 +50,36 @@ export function SessionMenu({
greetingLabel: string;
profileLabel: string;
adminLabel: string;
retryLabel: string;
}) {
const { actor, isLoading } = useActor();
const { actor, isLoading, isSignedOut, error, refresh } = useActor();
const [busy, setBusy] = useState(false);

if (isLoading) {
return null;
}

if (error !== undefined || (actor === null && !isSignedOut)) {
return (
<Button variant="outline" onClick={() => void refresh()} title={error?.message}>
{retryLabel}
</Button>
);
}

if (actor === null) {
return (
<Button size="default" onClick={() => void signInWithGitHub(`/${locale}/welcome`)}>
<Button
size="default"
onClick={() => {
signInWithGitHub(`/${locale}/welcome`).catch((signInError: unknown) => {
console.error("Failed to start GitHub sign-in", signInError);
toast.error(
signInError instanceof Error ? signInError.message : "Could not start GitHub sign-in",
);
});
}}
>
<GithubLogoIcon data-icon="inline-start" />
{signInLabel}
</Button>
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/components/modules/common/sign-in-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { signInWithGitHub } from "@/lib/auth-client";
Expand All @@ -17,7 +18,13 @@ export function SignInPanel({ label, locale }: { label: string; locale: Locale }
disabled={busy}
onClick={() => {
setBusy(true);
signInWithGitHub(`/${locale}/welcome`).catch(() => setBusy(false));
signInWithGitHub(`/${locale}/welcome`).catch((error: unknown) => {
console.error("Failed to start GitHub sign-in", error);
toast.error(
error instanceof Error ? error.message : "Could not start GitHub sign-in",
);
setBusy(false);
});
}}
>
{label}
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/components/modules/common/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function SiteHeader({ locale }: { locale: Locale }) {
greetingLabel={dict.session.greeting}
profileLabel={dict.nav.myProfile}
adminLabel={dict.nav.admin}
retryLabel={dict.session.retryAccount}
/>

<DropdownMenu>
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/components/modules/landing/hero-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ export function HeroSection({ dict, locale }: { dict: Dictionary; locale: Locale
<p className="text-base text-pretty text-secondary-foreground">{dict.home.lead}</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<HeroSignIn label={dict.session.signIn} locale={locale} />
<HeroSignIn
label={dict.session.signIn}
profileLabel={dict.nav.myProfile}
retryLabel={dict.session.retryAccount}
locale={locale}
/>
<Button
size="lg"
variant="outline"
Expand Down
48 changes: 45 additions & 3 deletions apps/api/src/components/modules/landing/hero-sign-in.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,64 @@
"use client";

import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { useActor } from "@/hooks";
import { signInWithGitHub } from "@/lib/auth-client";
import type { Locale } from "@/lib/i18n";
import { type Locale, localePath } from "@/lib/i18n";

/** Hero call to action. Client-side because sign-in starts an OAuth redirect. */
export function HeroSignIn({ label, locale }: { label: string; locale: Locale }) {
export function HeroSignIn({
label,
profileLabel,
retryLabel,
locale,
}: {
label: string;
profileLabel: string;
retryLabel: string;
locale: Locale;
}) {
const { actor, isLoading, isSignedOut, error, refresh } = useActor();
const [busy, setBusy] = useState(false);

if (isLoading) {
return <span className="h-10 w-40 animate-pulse rounded-md bg-muted" aria-hidden="true" />;
}

if (error !== undefined || (actor === null && !isSignedOut)) {
return (
<Button size="lg" variant="outline" onClick={() => void refresh()} title={error?.message}>
{retryLabel}
</Button>
);
}

if (actor !== null) {
return (
<Button
size="lg"
nativeButton={false}
render={<Link href={localePath(locale, "/profile")} />}
>
{profileLabel}
</Button>
);
}

return (
<Button
size="lg"
disabled={busy}
onClick={() => {
setBusy(true);
signInWithGitHub(`/${locale}/welcome`).catch(() => setBusy(false));
signInWithGitHub(`/${locale}/welcome`).catch((error: unknown) => {
console.error("Failed to start GitHub sign-in", error);
toast.error(error instanceof Error ? error.message : "Could not start GitHub sign-in");
setBusy(false);
});
}}
>
<svg
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/hooks/use-actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export type Actor = ProfileResponse;
/**
* The signed-in actor, backed by GET /v1/profile (cookie session). A 401 means
* "signed out" — an expected state, not an error — so `error` stays
* undefined and callers branch on `actor === null` once `isLoading` is
* false. Any other failure is surfaced through `error`.
* undefined and callers branch on `isSignedOut` once `isLoading` is false.
* Any other failure is surfaced through `error`.
*/
export function useActor() {
const { data, error, isLoading, mutate } = useSWR<Actor, ApiError>(
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const en = {
signIn: "Sign in with GitHub",
signOut: "Sign out",
greeting: "Signed in as",
retryAccount: "Retry account",
},
welcome: {
kicker: "Onboarding",
Expand Down Expand Up @@ -369,6 +370,7 @@ const ne: Dictionary = {
signIn: "GitHub बाट साइन इन",
signOut: "साइन आउट",
greeting: "साइन इन:",
retryAccount: "खाता फेरि जाँच्नुहोस्",
},
welcome: {
kicker: "स्वागत",
Expand Down
80 changes: 80 additions & 0 deletions apps/api/tests/unit/hero-sign-in.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/hooks", () => ({ useActor: vi.fn() }));

import { HeroSignIn } from "@/components/modules/landing/hero-sign-in";
import { useActor } from "@/hooks";

const mockedUseActor = vi.mocked(useActor);
const props = {
label: "Sign in with GitHub",
profileLabel: "My profile",
retryLabel: "Retry account",
locale: "en" as const,
};

describe("homepage authentication action", () => {
beforeEach(() => {
mockedUseActor.mockReset();
});

it("offers sign-in only after a confirmed signed-out response", () => {
mockedUseActor.mockReturnValue({
actor: null,
isLoading: false,
isSignedOut: true,
error: undefined,
refresh: vi.fn(),
});

const html = renderToStaticMarkup(<HeroSignIn {...props} />);
expect(html).toContain("Sign in with GitHub");
expect(html).not.toContain("My profile");
});

it("links a signed-in member to their profile instead of offering sign-in", () => {
mockedUseActor.mockReturnValue({
actor: { member: { displayName: "Member" }, isAdmin: false } as ReturnType<
typeof useActor
>["actor"],
isLoading: false,
isSignedOut: false,
error: undefined,
refresh: vi.fn(),
});

const html = renderToStaticMarkup(<HeroSignIn {...props} />);
expect(html).toContain("My profile");
expect(html).toContain('href="/en/profile"');
expect(html).not.toContain("Sign in with GitHub");
});

it("does not mislabel a profile-fetch failure as signed out", () => {
mockedUseActor.mockReturnValue({
actor: null,
isLoading: false,
isSignedOut: false,
error: new Error("Profile unavailable"),
refresh: vi.fn(),
});

const html = renderToStaticMarkup(<HeroSignIn {...props} />);
expect(html).toContain("Retry account");
expect(html).not.toContain("Sign in with GitHub");
});

it("does not offer sign-in when account state is unresolved", () => {
mockedUseActor.mockReturnValue({
actor: null,
isLoading: false,
isSignedOut: false,
error: undefined,
refresh: vi.fn(),
});

const html = renderToStaticMarkup(<HeroSignIn {...props} />);
expect(html).toContain("Retry account");
expect(html).not.toContain("Sign in with GitHub");
});
});
2 changes: 1 addition & 1 deletion apps/api/vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default defineConfig({
},
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
globalSetup: ["tests/global-setup.ts"],
env: {
NODE_ENV: "test",
Expand Down
Loading