Files
Lyra/web/src/app/_ui/contents-nav.tsx
T
Jonathan 190e0ef4b6 feat(web): minimal shared-password auth gate
The web app had no auth — anyone reaching the host could browse the library,
trigger downloads, and read/overwrite the stored credentials in Settings. Adds
an opt-in single-shared-password gate:

- Next.js middleware redirects unauthenticated page requests to /login and
  returns 401 for /api/*; static assets are excluded via matcher.
- Auth is enabled ONLY when LYRA_PASSWORD is set (unset ⇒ app stays open, so
  existing deployments are unchanged). Documented in README "Security".
- The session cookie is httpOnly and carries HMAC-SHA256(password) keyed by
  LYRA_SECRET_KEY — unforgeable, and the plaintext password never touches the
  client. Edge-safe Web Crypto so one lib/auth.ts serves middleware + route.
- Cookie is NOT Secure: Lyra serves plain HTTP on the LAN; a Secure cookie would
  loop. Works behind an HTTPS proxy too.
- /login page + form, a Sign out button (shown only when auth is enabled), and
  chrome (nav + worker-status poll) suppressed on /login.
- docker-compose passes LYRA_PASSWORD to web; .env.example documents it.

Verified live end-to-end (built + next start): no-cookie page→307 /login,
API→401, wrong password→401, correct→200+cookie, authed page/API→200, forged
cookie→401, and the auth-disabled (no LYRA_PASSWORD) path stays fully open.
web 133 tests green, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:12:51 +02:00

32 lines
949 B
TypeScript

"use client";
import { usePathname } from "next/navigation";
const LINKS: { href: string; label: string }[] = [
{ href: "/", label: "The Floor" },
{ href: "/library", label: "Library" },
{ href: "/artists", label: "Artists" },
{ href: "/discover", label: "Discover" },
{ href: "/lastfm", label: "Last.fm" },
{ href: "/wanted", label: "Wanted" },
];
export function ContentsNav() {
const pathname = usePathname();
if (pathname === "/login") return null; // no section nav on the login screen
const active = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
return (
<nav className="contents">
{LINKS.map((l) => (
<a key={l.href} href={l.href} className={active(l.href) ? "here" : ""}>
{l.label}
</a>
))}
<span className="sep" />
<a href="/settings" className={active("/settings") ? "here" : ""}>
Settings
</a>
</nav>
);
}