feat: implement local authentication mode and update related components

This commit is contained in:
Abhimanyu Saharan
2026-02-11 19:10:23 +05:30
parent 0ff645f795
commit 06ff1a9720
23 changed files with 563 additions and 93 deletions

View File

@@ -2,8 +2,14 @@
# Must be reachable from the browser (host).
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
CLERK_SECRET_KEY=YOUR_SECRET_KEY
# Auth mode: clerk or local.
# - clerk: Clerk sign-in flow
# - local: shared bearer token entered in UI
NEXT_PUBLIC_AUTH_MODE=local
# Clerk auth (used when NEXT_PUBLIC_AUTH_MODE=clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards

View File

@@ -15,6 +15,8 @@ COPY . ./
# Allows configuring the API URL at build time.
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ARG NEXT_PUBLIC_AUTH_MODE
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
RUN npm run build
@@ -22,10 +24,12 @@ FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ARG NEXT_PUBLIC_AUTH_MODE
# If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well
# (but note some values may be baked at build time).
ENV NEXT_PUBLIC_API_URL=http://localhost:8000
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
COPY --from=builder /app/.next ./.next
# `public/` is optional in Next.js apps; repo may not have it.

View File

@@ -4,7 +4,9 @@ This package is the **Next.js** web UI for OpenClaw Mission Control.
- Talks to the Mission Control **backend** over HTTP (typically `http://localhost:8000`).
- Uses **React Query** for data fetching.
- Can optionally enable **Clerk** authentication (disabled by default unless you provide a _real_ Clerk publishable key).
- Supports two auth modes:
- **local** shared bearer token mode (self-host default)
- **clerk** mode
## Prerequisites
@@ -53,27 +55,23 @@ Example:
NEXT_PUBLIC_API_URL=http://localhost:8000
```
### Optional: Clerk authentication
### Authentication mode
Clerk is **optional**.
Set `NEXT_PUBLIC_AUTH_MODE` to one of:
The app only enables Clerk when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` looks like a real key.
Implementation detail: we gate on a conservative regex (`pk_test_...` / `pk_live_...`) in `src/auth/clerkKey.ts`.
- `local` (default for self-host)
- `clerk`
#### Env vars
For `local` mode:
- users enter the token in the local login screen
- requests use that token as `Authorization: Bearer ...`
For `clerk` mode, configure:
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
- If **unset/blank/placeholder**, Clerk is treated as **disabled**.
- `CLERK_SECRET_KEY`
- Required only if you enable Clerk features that need server-side verification.
- Redirect URLs (optional; used by Clerk UI flows):
- `NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL`
- `NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL`
- `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL`
- `NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL`
**Important:** `frontend/.env.example` contains placeholder values like `YOUR_PUBLISHABLE_KEY`.
Those placeholders are _not_ valid keys and are intentionally treated as “Clerk disabled”.
- optional Clerk redirect env vars
## How the frontend talks to the backend
@@ -107,7 +105,7 @@ All Orval-generated requests go through the custom mutator (`src/api/mutator.ts`
It will:
- set `Content-Type: application/json` when there is a body and you didnt specify a content type
- add `Authorization: Bearer <token>` automatically **if** Clerk is enabled and there is an active Clerk session in the browser
- add `Authorization: Bearer <token>` automatically from local mode token or Clerk session
- parse errors into an `ApiError` with status + parsed response body
## Common commands
@@ -149,11 +147,11 @@ cp .env.example .env.local
- Confirm `NEXT_PUBLIC_API_URL` points to the correct host/port.
- If accessing from another device (LAN), use a reachable backend URL (not `localhost`).
### Clerk redirects / auth UI shows unexpectedly
### Wrong auth mode UI
Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` publishable key.
- Remove/blank `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in your `.env.local` to force Clerk off.
- Ensure `NEXT_PUBLIC_AUTH_MODE` matches backend `AUTH_MODE`.
- For local mode, set `NEXT_PUBLIC_AUTH_MODE=local`.
- For Clerk mode, set `NEXT_PUBLIC_AUTH_MODE=clerk` and a real Clerk publishable key.
### Dev server blocked by origin restrictions

View File

@@ -1,3 +1,5 @@
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
type ClerkSession = {
getToken: () => Promise<string>;
};
@@ -48,6 +50,12 @@ export const customFetch = async <T>(
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (isLocalAuthMode() && !headers.has("Authorization")) {
const token = getLocalAuthToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
}
if (!headers.has("Authorization")) {
const token = await resolveClerkToken();
if (token) {

View File

@@ -16,21 +16,33 @@ import {
} from "@clerk/nextjs";
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
function hasLocalAuthToken(): boolean {
return Boolean(getLocalAuthToken());
}
export function isClerkEnabled(): boolean {
// IMPORTANT: keep this in sync with AuthProvider; otherwise components like
// <SignedOut/> may render without a <ClerkProvider/> and crash during prerender.
if (isLocalAuthMode()) return false;
return isLikelyValidClerkPublishableKey(
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
);
}
export function SignedIn(props: { children: ReactNode }) {
if (isLocalAuthMode()) {
return hasLocalAuthToken() ? <>{props.children}</> : null;
}
if (!isClerkEnabled()) return null;
return <ClerkSignedIn>{props.children}</ClerkSignedIn>;
}
export function SignedOut(props: { children: ReactNode }) {
if (isLocalAuthMode()) {
return hasLocalAuthToken() ? null : <>{props.children}</>;
}
if (!isClerkEnabled()) return <>{props.children}</>;
return <ClerkSignedOut>{props.children}</ClerkSignedOut>;
}
@@ -49,6 +61,13 @@ export function SignOutButton(
}
export function useUser() {
if (isLocalAuthMode()) {
return {
isLoaded: true,
isSignedIn: hasLocalAuthToken(),
user: null,
} as const;
}
if (!isClerkEnabled()) {
return { isLoaded: true, isSignedIn: false, user: null } as const;
}
@@ -56,6 +75,16 @@ export function useUser() {
}
export function useAuth() {
if (isLocalAuthMode()) {
const token = getLocalAuthToken();
return {
isLoaded: true,
isSignedIn: Boolean(token),
userId: token ? "local-user" : null,
sessionId: token ? "local-session" : null,
getToken: async () => token,
} as const;
}
if (!isClerkEnabled()) {
return {
isLoaded: true,

View File

@@ -0,0 +1,43 @@
"use client";
let localToken: string | null = null;
const STORAGE_KEY = "mc_local_auth_token";
export function isLocalAuthMode(): boolean {
return process.env.NEXT_PUBLIC_AUTH_MODE === "local";
}
export function setLocalAuthToken(token: string): void {
localToken = token;
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(STORAGE_KEY, token);
} catch {
// Ignore storage failures (private mode / policy).
}
}
export function getLocalAuthToken(): string | null {
if (localToken) return localToken;
if (typeof window === "undefined") return null;
try {
const stored = window.sessionStorage.getItem(STORAGE_KEY);
if (stored) {
localToken = stored;
return stored;
}
} catch {
// Ignore storage failures (private mode / policy).
}
return null;
}
export function clearLocalAuthToken(): void {
localToken = null;
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage failures (private mode / policy).
}
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { Lock } from "lucide-react";
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";
export function LocalAuthLogin() {
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const cleaned = token.trim();
if (!cleaned) {
setError("Bearer token is required.");
return;
}
setLocalAuthToken(cleaned);
setError(null);
window.location.reload();
};
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-3">
<div className="mx-auto rounded-full bg-slate-100 p-3 text-slate-700">
<Lock className="h-5 w-5" />
</div>
<div className="space-y-1 text-center">
<h1 className="text-xl font-semibold text-slate-900">
Local Authentication
</h1>
<p className="text-sm text-slate-600">
Enter the shared local token configured as
<code className="mx-1 rounded bg-slate-100 px-1 py-0.5 text-xs">
LOCAL_AUTH_TOKEN
</code>
on the backend.
</p>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-3">
<Input
type="password"
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="Paste token"
autoFocus
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<Button type="submit" className="w-full">
Continue
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { SignOutButton, useUser } from "@/auth/clerk";
import { clearLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
import {
Activity,
Bot,
@@ -36,13 +37,15 @@ export function UserMenu({
}: UserMenuProps) {
const [open, setOpen] = useState(false);
const { user } = useUser();
if (!user) return null;
const localMode = isLocalAuthMode();
if (!user && !localMode) return null;
const avatarUrl = user.imageUrl ?? null;
const avatarLabelSource = displayNameFromDb ?? user.id ?? "U";
const avatarUrl = localMode ? null : (user?.imageUrl ?? null);
const avatarLabelSource =
displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U";
const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase();
const displayName = displayNameFromDb ?? "Account";
const displayEmail = displayEmailFromDb ?? "";
const displayName = displayNameFromDb ?? (localMode ? "Local User" : "Account");
const displayEmail = displayEmailFromDb ?? (localMode ? "local@localhost" : "");
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -166,16 +169,31 @@ export function UserMenu({
<div className="my-2 h-px bg-[color:var(--neutral-200,var(--border))]" />
<SignOutButton>
{localMode ? (
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[color:var(--neutral-800,var(--text))] transition hover:bg-[color:var(--neutral-100,var(--surface-muted))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
onClick={() => setOpen(false)}
onClick={() => {
clearLocalAuthToken();
setOpen(false);
window.location.reload();
}}
>
<LogOut className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
Sign out
</button>
</SignOutButton>
) : (
<SignOutButton>
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[color:var(--neutral-800,var(--text))] transition hover:bg-[color:var(--neutral-100,var(--surface-muted))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
onClick={() => setOpen(false)}
>
<LogOut className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
Sign out
</button>
</SignOutButton>
)}
</div>
</PopoverContent>
</Popover>

View File

@@ -4,8 +4,17 @@ import { ClerkProvider } from "@clerk/nextjs";
import type { ReactNode } from "react";
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin";
export function AuthProvider({ children }: { children: ReactNode }) {
if (isLocalAuthMode()) {
if (!getLocalAuthToken()) {
return <LocalAuthLogin />;
}
return <>{children}</>;
}
const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
const afterSignOutUrl =
process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL ?? "/";

View File

@@ -4,6 +4,7 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
const isClerkEnabled = () =>
process.env.NEXT_PUBLIC_AUTH_MODE !== "local" &&
isLikelyValidClerkPublishableKey(
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
);