From 72b873845faccd2768146592c229ae5d138af421 Mon Sep 17 00:00:00 2001
From: null
Date: Tue, 19 May 2026 22:28:34 -0500
Subject: [PATCH] feat: auth token env bypass
---
.env.example | 6 +++
README.md | 30 ++++++++++-
compose.yml | 2 +
frontend/.env.example | 8 ++-
frontend/src/auth/localAuth.ts | 5 ++
.../components/organisms/LocalAuthLogin.tsx | 52 ++-----------------
.../src/components/providers/AuthProvider.tsx | 8 +++
7 files changed, 60 insertions(+), 51 deletions(-)
diff --git a/.env.example b/.env.example
index 786f925..26fd203 100644
--- a/.env.example
+++ b/.env.example
@@ -29,3 +29,9 @@ LOCAL_AUTH_TOKEN=
# Use `auto` to target the same host currently serving Pipeline on port 8001.
# Example (explicit override): NEXT_PUBLIC_API_URL=https://mc.example.com
NEXT_PUBLIC_API_URL=auto
+NEXT_PUBLIC_AUTH_MODE=local
+# Local auth token (used when NEXT_PUBLIC_AUTH_MODE=local).
+# When set: the app loads directly without showing the login screen.
+# When unset: users are prompted to enter their token on first load.
+# Must be at least 50 characters and match LOCAL_AUTH_TOKEN above.
+NEXT_PUBLIC_LOCAL_AUTH_TOKEN=
diff --git a/README.md b/README.md
index ec9993a..dbc594f 100644
--- a/README.md
+++ b/README.md
@@ -149,7 +149,35 @@ Mission Control supports two authentication modes:
- `local`: shared bearer token mode (default for self-hosted use)
- `clerk`: Clerk JWT mode
-Environment templates:
+### Local auth mode
+
+Set `AUTH_MODE=local` (backend) and `NEXT_PUBLIC_AUTH_MODE=local` (frontend).
+
+**Option 1 — Token in environment (recommended for self-hosted)**
+
+Set `NEXT_PUBLIC_LOCAL_AUTH_TOKEN` in the frontend environment. The app loads directly with no login screen. The token is used automatically for every API call.
+
+```env
+NEXT_PUBLIC_AUTH_MODE=local
+NEXT_PUBLIC_LOCAL_AUTH_TOKEN=your-bearer-token-here
+```
+
+**Option 2 — Token entered on load**
+
+Leave `NEXT_PUBLIC_LOCAL_AUTH_TOKEN` unset. On first load, users are presented with a login screen and must paste their bearer token before accessing the app. The token is stored in `sessionStorage` for the duration of the browser session.
+
+```env
+NEXT_PUBLIC_AUTH_MODE=local
+# NEXT_PUBLIC_LOCAL_AUTH_TOKEN is not set — login screen will appear
+```
+
+The bearer token must match `LOCAL_AUTH_TOKEN` set on the backend (minimum 50 characters).
+
+### Clerk mode
+
+Set `AUTH_MODE=clerk` and configure `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`.
+
+### Environment templates
- Root: [`.env.example`](./.env.example)
- Backend: [`backend/.env.example`](./backend/.env.example)
diff --git a/compose.yml b/compose.yml
index a458c7f..ba84529 100644
--- a/compose.yml
+++ b/compose.yml
@@ -58,6 +58,7 @@ services:
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-auto}
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
+ NEXT_PUBLIC_LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
# Optional, user-managed env file.
# IMPORTANT: do NOT load `.env.example` here because it contains non-empty
# placeholder Clerk keys, which can accidentally flip Clerk "on".
@@ -66,6 +67,7 @@ services:
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-auto}
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
+ NEXT_PUBLIC_LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
depends_on:
- backend
ports:
diff --git a/frontend/.env.example b/frontend/.env.example
index 3de0593..df40e7b 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -5,9 +5,15 @@ NEXT_PUBLIC_API_URL=auto
# Auth mode: clerk or local.
# - clerk: Clerk sign-in flow
-# - local: shared bearer token entered in UI
+# - local: shared bearer token, entered in UI or pre-set via env
NEXT_PUBLIC_AUTH_MODE=local
+# Local auth token (used when NEXT_PUBLIC_AUTH_MODE=local).
+# When set: the app loads directly without showing the login screen.
+# When unset: users are prompted to enter their token on first load.
+# Must be at least 50 characters and match LOCAL_AUTH_TOKEN on the backend.
+NEXT_PUBLIC_LOCAL_AUTH_TOKEN=
+
# Clerk auth (used when NEXT_PUBLIC_AUTH_MODE=clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards
diff --git a/frontend/src/auth/localAuth.ts b/frontend/src/auth/localAuth.ts
index cfe59c9..32e0b10 100644
--- a/frontend/src/auth/localAuth.ts
+++ b/frontend/src/auth/localAuth.ts
@@ -9,6 +9,11 @@ export function isLocalAuthMode(): boolean {
return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Local;
}
+/** Token pre-configured via env — skips the login screen when set. */
+export function getEnvToken(): string | null {
+ return process.env.NEXT_PUBLIC_LOCAL_AUTH_TOKEN?.trim() || null;
+}
+
export function setLocalAuthToken(token: string): void {
localToken = token;
if (typeof window === "undefined") return;
diff --git a/frontend/src/components/organisms/LocalAuthLogin.tsx b/frontend/src/components/organisms/LocalAuthLogin.tsx
index d76216c..da121e0 100644
--- a/frontend/src/components/organisms/LocalAuthLogin.tsx
+++ b/frontend/src/components/organisms/LocalAuthLogin.tsx
@@ -7,39 +7,9 @@ import { setLocalAuthToken } from "@/auth/localAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
-import { getApiBaseUrl } from "@/lib/api-base";
const LOCAL_AUTH_TOKEN_MIN_LENGTH = 50;
-async function validateLocalToken(token: string): Promise {
- let baseUrl: string;
- try {
- baseUrl = getApiBaseUrl();
- } catch {
- return "Unable to resolve backend URL.";
- }
-
- let response: Response;
- try {
- response = await fetch(`${baseUrl}/api/v1/users/me`, {
- method: "GET",
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
- } catch {
- return "Unable to reach backend to validate token.";
- }
-
- if (response.ok) {
- return null;
- }
- if (response.status === 401 || response.status === 403) {
- return "Token is invalid.";
- }
- return `Unable to validate token (HTTP ${response.status}).`;
-}
-
type LocalAuthLoginProps = {
onAuthenticated?: () => void;
};
@@ -49,9 +19,8 @@ const defaultOnAuthenticated = () => window.location.reload();
export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
const [token, setToken] = useState("");
const [error, setError] = useState(null);
- const [isValidating, setIsValidating] = useState(false);
- const handleSubmit = async (event: React.FormEvent) => {
+ const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const cleaned = token.trim();
if (!cleaned) {
@@ -64,15 +33,6 @@ export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
);
return;
}
-
- setIsValidating(true);
- const validationError = await validateLocalToken(cleaned);
- setIsValidating(false);
- if (validationError) {
- setError(validationError);
- return;
- }
-
setLocalAuthToken(cleaned);
setError(null);
(onAuthenticated ?? defaultOnAuthenticated)();
@@ -120,7 +80,6 @@ export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
onChange={(event) => setToken(event.target.value)}
placeholder="Paste token"
autoFocus
- disabled={isValidating}
className="font-mono"
/>
@@ -133,13 +92,8 @@ export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
Token must be at least {LOCAL_AUTH_TOKEN_MIN_LENGTH} characters.
)}
-