{children}
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 2687ac1..d44f64b 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,12 +1,5 @@
-// Placeholder home page. The sign-in screen and the feed UI are built in
-// later tasks (tlv84yb7n and the feed tasks). This task delivers the server
-// session routes under /api/session.
+import { App } from "@/components/App";
export default function HomePage() {
- return (
-
-
Minnect feed browser
-
The sign-in screen is built in a later task.
-
- );
+ return ;
}
diff --git a/src/components/App.tsx b/src/components/App.tsx
new file mode 100644
index 0000000..1bf4a2a
--- /dev/null
+++ b/src/components/App.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { loadSession, type Person } from "@/lib/auth-client";
+import { SignInForm } from "./SignInForm";
+import { Logo } from "./Logo";
+
+type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
+
+export function App() {
+ const [state, setState] = useState({ kind: "loading" });
+
+ useEffect(() => {
+ let live = true;
+ loadSession().then((person) => {
+ if (live) setState(person ? { kind: "in", person } : { kind: "out" });
+ });
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ if (state.kind === "loading") {
+ return (
+
+
Loading...
+
+ );
+ }
+ if (state.kind === "out") {
+ return setState({ kind: "in", person })} />;
+ }
+ // Placeholder shell. The home feed and sign-out are later tasks.
+ return (
+
+
+
+
+ {state.person.name}
+ {state.person.email}
+
+
+
+ );
+}
diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx
new file mode 100644
index 0000000..79ba50c
--- /dev/null
+++ b/src/components/Logo.tsx
@@ -0,0 +1,8 @@
+export function Logo({ size = 44 }: { size?: number }) {
+ return (
+
+ );
+}
diff --git a/src/components/SignInForm.tsx b/src/components/SignInForm.tsx
new file mode 100644
index 0000000..bd5db95
--- /dev/null
+++ b/src/components/SignInForm.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { signIn, type Person } from "@/lib/auth-client";
+import { Logo } from "./Logo";
+
+export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void }) {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function submit(e: FormEvent) {
+ e.preventDefault();
+ if (busy) return;
+ setBusy(true);
+ setError(null);
+ const result = await signIn(email.trim(), password);
+ setBusy(false);
+ if (result.ok) {
+ setPassword("");
+ onSignedIn(result.person);
+ } else {
+ setPassword("");
+ setError(result.message);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts
new file mode 100644
index 0000000..99e2d58
--- /dev/null
+++ b/src/lib/auth-client.ts
@@ -0,0 +1,51 @@
+// Browser-side calls to this app's own /api/session. No token, password or
+// session id is stored or logged here; the session lives in an httpOnly cookie.
+
+export type Person = { name: string; email: string; pictureUrl: string | null };
+
+export const WRONG_CREDENTIALS = "Email or password is incorrect.";
+export const GENERIC_FAILURE = "Could not sign you in. Try again.";
+
+type Fetch = typeof fetch;
+
+function toPerson(body: unknown): Person | null {
+ if (!body || typeof body !== "object") return null;
+ const r = body as Record;
+ if (typeof r.email !== "string") return null;
+ return {
+ name: typeof r.name === "string" ? r.name : "",
+ email: r.email,
+ pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null,
+ };
+}
+
+/** GET /api/session: the person, or null when signed out or unreachable. */
+export async function loadSession(f: Fetch = fetch): Promise {
+ try {
+ const res = await f("/api/session", { credentials: "same-origin", cache: "no-store" });
+ if (!res.ok) return null;
+ return toPerson(await res.json());
+ } catch {
+ return null;
+ }
+}
+
+export type SignInResult = { ok: true; person: Person } | { ok: false; message: string };
+
+/** POST /api/session. A 401 is always the one wrong-credentials message. */
+export async function signIn(email: string, password: string, f: Fetch = fetch): Promise {
+ try {
+ const res = await f("/api/session", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ email, password }),
+ });
+ if (res.status === 401) return { ok: false, message: WRONG_CREDENTIALS };
+ if (!res.ok) return { ok: false, message: GENERIC_FAILURE };
+ const person = toPerson(await res.json());
+ return person ? { ok: true, person } : { ok: false, message: GENERIC_FAILURE };
+ } catch {
+ return { ok: false, message: GENERIC_FAILURE };
+ }
+}
diff --git a/tests/auth-client.test.ts b/tests/auth-client.test.ts
new file mode 100644
index 0000000..4a65022
--- /dev/null
+++ b/tests/auth-client.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it, vi } from "vitest";
+import { loadSession, signIn, WRONG_CREDENTIALS } from "@/lib/auth-client";
+
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
+
+describe("signIn", () => {
+ it("wrong password gives the one message", async () => {
+ const f = vi.fn(async () => json({ error: "Email or password is incorrect." }, 401));
+ expect(await signIn("a@b.co", "bad", f as unknown as typeof fetch)).toEqual({ ok: false, message: WRONG_CREDENTIALS });
+ });
+ it("success returns the person and posts JSON to /api/session", async () => {
+ const f = vi.fn(async () => json({ name: "Ann", email: "a@b.co", pictureUrl: null }));
+ const r = await signIn("a@b.co", "pw", f as unknown as typeof fetch);
+ expect(r).toEqual({ ok: true, person: { name: "Ann", email: "a@b.co", pictureUrl: null } });
+ expect(f.mock.calls[0]).toMatchObject(["/api/session", { method: "POST" }]);
+ });
+ it("network failure is not the wrong-password message", async () => {
+ const f = vi.fn(async () => { throw new Error("down"); });
+ const r = await signIn("a@b.co", "pw", f as unknown as typeof fetch);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.message).not.toBe(WRONG_CREDENTIALS);
+ });
+});
+
+describe("loadSession", () => {
+ it("401 means signed out", async () => {
+ const f = vi.fn(async () => json({ error: "sign_in" }, 401));
+ expect(await loadSession(f as unknown as typeof fetch)).toBeNull();
+ });
+ it("200 returns the person", async () => {
+ const f = vi.fn(async () => json({ name: "Ann", email: "a@b.co", pictureUrl: "u" }));
+ expect((await loadSession(f as unknown as typeof fetch))?.email).toBe("a@b.co");
+ });
+});