Skip to content
Merged
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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. |

Expand Down
60 changes: 60 additions & 0 deletions app/login/actions.ts
Original file line number Diff line number Diff line change
@@ -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<LoginState> {
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<void> {
const jar = await cookies();
jar.delete(SESSION_COOKIE);
redirect("/login");
}
28 changes: 28 additions & 0 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoginForm next={next} />;
}
77 changes: 77 additions & 0 deletions components/auth/login-form.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-full items-center justify-center bg-background px-4 py-16">
<div className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-3 text-center">
<Image
src="/logo.png"
alt="Minitor"
width={48}
height={48}
priority
className="rounded-xl"
/>
<div className="space-y-1">
<h1 className="font-serif text-2xl">Minitor</h1>
<p className="text-[13px] text-muted-foreground">
This instance is password-protected. Enter the password to
continue.
</p>
</div>
</div>

<form action={formAction} className="space-y-3">
<input type="hidden" name="next" value={next} />

<div className="relative">
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="password"
name="password"
autoFocus
autoComplete="current-password"
required
placeholder="Password"
aria-invalid={state.error ? true : undefined}
aria-describedby={state.error ? "login-error" : undefined}
className="h-10 pl-9"
/>
</div>

{state.error && (
<p
id="login-error"
role="alert"
className="text-[12.5px] text-destructive"
>
{state.error}
</p>
)}

<Button
type="submit"
size="lg"
disabled={pending}
className="h-10 w-full"
>
{pending ? "Logging in…" : "Log in"}
</Button>
</form>
</div>
</main>
);
}
14 changes: 13 additions & 1 deletion components/settings/settings-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -211,6 +212,17 @@ export function SettingsDialog({ open, onOpenChange }: Props) {
</div>

<DialogFooter>
{IS_HOSTED_CLIENT && (
<Button
type="button"
variant="ghost"
onClick={() => void logout()}
className="mr-auto text-muted-foreground"
>
<LogOut className="size-3.5" />
Log out
</Button>
)}
<Button
type="button"
variant="outline"
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ services:
condition: service_healthy
environment:
DATABASE_URL: postgres://minitor:minitor@db:5432/minitor
# Optional Basic-Auth gate — set this to require a password to reach the app.
# Login gate — the app is served behind a /login page and refuses to serve
# anything until this is set (the image runs in hosted mode). Required.
MINITOR_PASSWORD: ${MINITOR_PASSWORD:-}
# API keys are read from the host env here (the in-app editor is disabled
# in hosted mode). Leave unset to run keyless columns only.
Expand Down
86 changes: 86 additions & 0 deletions lib/auth/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Session tokens for the hosted single-password login gate.
//
// Minitor has no per-user auth — decks and columns are global — so a hosted
// instance is protected by ONE shared password (`MINITOR_PASSWORD`). Logging in
// mints a signed, expiring token that we store in an HttpOnly cookie; the
// proxy verifies it on every request. There is no session store: the token
// is self-describing (an expiry + an HMAC over it), so it validates statelessly.
//
// The HMAC key IS the password. Two consequences we rely on:
// - No separate secret to configure — "password in env" is the whole config.
// - Rotating `MINITOR_PASSWORD` invalidates every existing session for free,
// because old signatures no longer verify under the new key.
//
// Everything here uses only Web Crypto + TextEncoder + btoa, so the SAME module
// runs in the Edge proxy runtime and in Node server actions/route handlers.

export const SESSION_COOKIE = "minitor_session";

// How long a login lasts. Also the cookie Max-Age. A personal gate; long-lived
// is fine and avoids surprise logouts on an always-open dashboard tab.
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days

const encoder = new TextEncoder();

function base64url(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

async function hmac(key: string, data: string): Promise<string> {
const cryptoKey = await crypto.subtle.importKey(
"raw",
encoder.encode(key),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(data));
return base64url(new Uint8Array(sig));
}

// Constant-time for equal-length inputs. Length is allowed to leak via the early
// return — signatures are a fixed length here, so nothing sensitive leaks.
function timingSafeEqual(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;
}

// `<expiryMs>.<sig>` where sig = HMAC-SHA256(password, "<expiryMs>").
export async function createSessionToken(
password: string,
now: number = Date.now(),
): Promise<string> {
const payload = String(now + SESSION_TTL_MS);
const sig = await hmac(password, payload);
return `${payload}.${sig}`;
}

export async function verifySessionToken(
token: string | undefined | null,
password: string,
now: number = Date.now(),
): Promise<boolean> {
if (!token) return false;
const dot = token.indexOf(".");
if (dot === -1) return false;
const payload = token.slice(0, dot);
const sig = token.slice(dot + 1);

const exp = Number(payload);
if (!Number.isInteger(exp) || exp <= now) return false;

const expected = await hmac(password, payload);
return timingSafeEqual(sig, expected);
}

// A `next` redirect target is safe only if it's a same-origin, single-slash
// path — this blocks open redirects (`//evil.com`, `https://…`) from the
// `?next=` query param that the proxy round-trips through the login page.
export function sanitizeNext(next: string | null | undefined): string {
if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
return next;
}
49 changes: 0 additions & 49 deletions middleware.ts

This file was deleted.

Loading