feat: implement local authentication mode and update related components
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 didn’t 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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
43
frontend/src/auth/localAuth.ts
Normal file
43
frontend/src/auth/localAuth.ts
Normal 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).
|
||||
}
|
||||
}
|
||||
65
frontend/src/components/organisms/LocalAuthLogin.tsx
Normal file
65
frontend/src/components/organisms/LocalAuthLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? "/";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user