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.

)} - diff --git a/frontend/src/components/providers/AuthProvider.tsx b/frontend/src/components/providers/AuthProvider.tsx index eccf107..b6dca74 100644 --- a/frontend/src/components/providers/AuthProvider.tsx +++ b/frontend/src/components/providers/AuthProvider.tsx @@ -6,8 +6,10 @@ import { useEffect, type ReactNode } from "react"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; import { clearLocalAuthToken, + getEnvToken, getLocalAuthToken, isLocalAuthMode, + setLocalAuthToken, } from "@/auth/localAuth"; import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin"; @@ -21,6 +23,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [localMode]); if (localMode) { + // If a token is pre-set via NEXT_PUBLIC_LOCAL_AUTH_TOKEN, use it and skip the login screen. + const envToken = getEnvToken(); + if (envToken) { + setLocalAuthToken(envToken); + return <>{children}; + } if (!getLocalAuthToken()) { return ; }