diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..1aafb4c --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,15 @@ +:root { --red: #E8202A; --ink: #111; --grey: #6b6b6b; --line: #d9d9d9; --bg: #f5f5f5; } +* { box-sizing: border-box; } +body { margin: 0; font-family: Roboto, system-ui, sans-serif; color: var(--ink); background: var(--bg); } +.center { min-height: 100vh; display: grid; place-items: center; padding: 16px; } +.card { width: 100%; max-width: 400px; background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 32px 24px; display: flex; flex-direction: column; gap: 8px; } +.card h1 { font-size: 22px; margin: 12px 0 8px; } +label { font-size: 14px; font-weight: 500; margin-top: 8px; } +input { font: inherit; padding: 12px; border: 1px solid #999; border-radius: 8px; background: #fff; color: var(--ink); } +input:focus-visible, button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } +button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; border: 0; border-radius: 8px; background: var(--red); color: #fff; cursor: pointer; } +button:disabled { opacity: .5; cursor: not-allowed; } +.error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--red); border-radius: 8px; color: #a8141b; background: #fff3f3; font-size: 14px; } +.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #fff; border-bottom: 1px solid var(--line); } +.shell header div { display: flex; flex-direction: column; font-size: 14px; } +.shell header span { color: var(--grey); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 56aea93..0e5fac5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,9 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; +import { Roboto } from "next/font/google"; +import "./globals.css"; + +const roboto = Roboto({ subsets: ["latin"], weight: ["400", "500", "700"] }); export const metadata: Metadata = { title: "Minnect feed browser", @@ -8,7 +12,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - + {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 ( +
+
+ +

Sign in to Minnect

+ {error && ( +

+ {error} +

+ )} + + setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> + + +
+ ); +} 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"); + }); +});