diff --git a/README.md b/README.md index a3f905d..3ace8f9 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Set `DATABASE_URL` in `.env.local`: Minitor ships as a single Docker image (`Dockerfile`, `output: "standalone"`) that any container host builds straight from source. It runs migrations on boot, then serves on `$PORT` (default 3000). Pair it with a Postgres database. -> **Heads up — Minitor has no per-user auth.** Decks and columns are global, so a public URL is a shared, editable dashboard. For anything reachable from the internet, set `MINITOR_PASSWORD` (below) to put the whole app behind a single-password gate. +> **Heads up — Minitor has no per-user auth.** Decks and columns are global, so a public URL is a shared, editable dashboard. `MINITOR_PASSWORD` puts the whole app behind a single-password **login page** (a signed session cookie, `/login`, "Log out" in Settings). In a hosted deployment it is **required** — the image fails closed and serves a lock screen until you set it, so an instance is never accidentally public. **Local / VPS — one command:** @@ -135,14 +135,14 @@ Brings up Postgres + Minitor, migrates automatically, serves at `http://localhos 1. New project → **Deploy from repo**. Railway auto-detects the `Dockerfile` (`railway.json` pins the builder + health check). 2. Add a **Postgres** plugin. Railway exposes its connection string as `DATABASE_URL` — reference it on the app service. -3. Set `MINITOR_PASSWORD` and any [API keys](#column-types) as service variables, then deploy. +3. Set `MINITOR_PASSWORD` (required — the app won't serve without it) and any [API keys](#column-types) as service variables, then deploy. **Hosting env vars:** | Var | Purpose | |---|---| | `DATABASE_URL` | `postgres://…` (compose/Railway) or a Neon URL. Required in hosted mode — `memory:`/PGlite is ephemeral. | -| `MINITOR_PASSWORD` | Enables an HTTP Basic-Auth gate over the whole app. Unset = open (local default). | +| `MINITOR_PASSWORD` | The login password. Gates the whole app behind a `/login` page (signed session cookie). **Required when hosted** — unset in hosted mode fails closed with a lock screen. Unset locally = open (dev default). | | `MINITOR_HOSTED` | Baked to `1` in the image. Disables the in-app key editor; keys come from env vars. | | `XAI_API_KEY`, `GITHUB_TOKEN`, … | Column [API keys](#column-types), read from the host environment. | diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..f7aded7 --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,60 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { + SESSION_COOKIE, + SESSION_TTL_MS, + createSessionToken, + sanitizeNext, +} from "@/lib/auth/session"; + +export interface LoginState { + error: string | null; +} + +// Constant-time compare of the submitted password against `MINITOR_PASSWORD`. +function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +export async function login( + _prev: LoginState, + formData: FormData, +): Promise { + const password = process.env.MINITOR_PASSWORD; + if (!password) { + // No gate configured — nothing to log into. + return { error: "This instance has no password configured." }; + } + + const provided = String(formData.get("password") ?? ""); + const next = sanitizeNext(String(formData.get("next") ?? "/")); + + if (!safeEqual(provided, password)) { + return { error: "Incorrect password." }; + } + + const token = await createSessionToken(password); + const jar = await cookies(); + jar.set(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: Math.floor(SESSION_TTL_MS / 1000), + }); + + // Throws NEXT_REDIRECT — navigates the client on success. + redirect(next); +} + +export async function logout(): Promise { + const jar = await cookies(); + jar.delete(SESSION_COOKIE); + redirect("/login"); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..ebfe92f --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { SESSION_COOKIE, sanitizeNext, verifySessionToken } from "@/lib/auth/session"; +import { LoginForm } from "@/components/auth/login-form"; + +export const metadata: Metadata = { + title: "Log in · Minitor", +}; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ next?: string }>; +}) { + const password = process.env.MINITOR_PASSWORD; + const next = sanitizeNext((await searchParams)?.next); + + // No gate configured → nothing to log into; send them to the app. + if (!password) redirect(next); + + // Already logged in → skip the form. + const token = (await cookies()).get(SESSION_COOKIE)?.value; + if (await verifySessionToken(token, password)) redirect(next); + + return ; +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000..d7d8918 --- /dev/null +++ b/components/auth/login-form.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useActionState } from "react"; +import Image from "next/image"; +import { LockKeyhole } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { login, type LoginState } from "@/app/login/actions"; + +const INITIAL: LoginState = { error: null }; + +export function LoginForm({ next }: { next: string }) { + const [state, formAction, pending] = useActionState(login, INITIAL); + + return ( +
+
+
+ Minitor +
+

Minitor

+

+ This instance is password-protected. Enter the password to + continue. +

+
+
+ +
+ + +
+ + +
+ + {state.error && ( + + )} + + +
+
+
+ ); +} diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 41ad1cd..0b6f7e2 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Check, ExternalLink, Eye, EyeOff, KeyRound } from "lucide-react"; +import { Check, ExternalLink, Eye, EyeOff, KeyRound, LogOut } from "lucide-react"; import { toast } from "sonner"; import { @@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ENV_KEYS } from "@/lib/env-keys"; import { IS_HOSTED_CLIENT } from "@/lib/hosted"; +import { logout } from "@/app/login/actions"; import { getEnvKeysStatus, setEnvKeys, @@ -211,6 +212,17 @@ export function SettingsDialog({ open, onOpenChange }: Props) { + {IS_HOSTED_CLIENT && ( + + )}