diff --git a/frontend/src/components/Chat/AccessCodeForm.tsx b/frontend/src/components/Chat/AccessCodeForm.tsx
new file mode 100644
index 00000000..54f9d9c0
--- /dev/null
+++ b/frontend/src/components/Chat/AccessCodeForm.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+/**
+ * Shown on /chat when the backend requires an access code and this browser
+ * hasn't entered it. The code is shared with testers until there are
+ * accounts; the server remembers it in a cookie.
+ */
+import { useState } from 'react';
+import {
+ Button,
+ Container,
+ Group,
+ PasswordInput,
+ Text,
+ Title,
+} from '@mantine/core';
+import { unlockChat } from './api';
+import { setChatStatus } from './useChatStatus';
+
+export function AccessCodeForm() {
+ const [code, setCode] = useState('');
+ const [error, setError] = useState
(null);
+ const [sending, setSending] = useState(false);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!code.trim() || sending) return;
+ setSending(true);
+ setError(null);
+ try {
+ setChatStatus(await unlockChat(code));
+ } catch (err) {
+ setError((err as Error).message);
+ setSending(false);
+ }
+ }
+
+ return (
+
+ Ask the Data
+
+ Chat is in testing. Enter the access code you were given.
+
+
+
+ );
+}
diff --git a/frontend/src/components/Chat/api.ts b/frontend/src/components/Chat/api.ts
index f4ef996c..5b353de4 100644
--- a/frontend/src/components/Chat/api.ts
+++ b/frontend/src/components/Chat/api.ts
@@ -1,7 +1,9 @@
/**
* Client for the data chat API (backend/chat/router.py). The chat routes only
* exist when the backend sets CHAT_ENABLED, so every caller must treat a
- * failed status check as "chat is off".
+ * failed status check as "chat is off". When the backend also sets
+ * CHAT_ACCESS_CODE, entering the code sets an HttpOnly cookie, so every request
+ * sends credentials (the dev frontend calls the API on another port).
*/
import { BASE_API_URL } from '@/config';
import type { AgentChartKind } from '@/components/Charts/AgentChart';
@@ -44,6 +46,8 @@ export interface ChatStatus {
enabled: boolean;
available: boolean;
resets_at: string | null;
+ // False until this browser has entered the access code.
+ unlocked: boolean;
}
export type ChatEvent =
@@ -70,11 +74,18 @@ export interface HistoryMessage {
content: string;
}
-const OFF: ChatStatus = { enabled: false, available: false, resets_at: null };
+const OFF: ChatStatus = {
+ enabled: false,
+ available: false,
+ resets_at: null,
+ unlocked: false,
+};
export async function fetchChatStatus(): Promise {
try {
- const res = await fetch(`${BASE_API_URL}/chat/status`);
+ const res = await fetch(`${BASE_API_URL}/chat/status`, {
+ credentials: 'include',
+ });
if (!res.ok) return OFF;
return (await res.json()) as ChatStatus;
} catch {
@@ -99,6 +110,7 @@ export async function* streamChat(
): AsyncGenerator {
const res = await fetch(`${BASE_API_URL}/chat`, {
method: 'POST',
+ credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal,
@@ -129,9 +141,22 @@ export async function* streamChat(
export async function fetchChartData(ref: DataRef): Promise {
const res = await fetch(`${BASE_API_URL}/chat/data`, {
method: 'POST',
+ credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ref),
});
if (!res.ok) throw new Error(await errorMessage(res));
return (await res.json()) as ChartData;
}
+
+/** Send the access code; resolves to the new status, or throws the reason. */
+export async function unlockChat(code: string): Promise {
+ const res = await fetch(`${BASE_API_URL}/chat/unlock`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code }),
+ });
+ if (!res.ok) throw new Error(await errorMessage(res));
+ return (await res.json()) as ChatStatus;
+}
diff --git a/frontend/src/components/Chat/useChatStatus.ts b/frontend/src/components/Chat/useChatStatus.ts
index 6bc65811..28565a98 100644
--- a/frontend/src/components/Chat/useChatStatus.ts
+++ b/frontend/src/components/Chat/useChatStatus.ts
@@ -3,16 +3,35 @@ import { fetchChatStatus, type ChatStatus } from './api';
// One request per page load, shared by the header and the chat page.
let pending: Promise | null = null;
+// Mounted hooks, told when the status changes after the first check.
+const listeners = new Set<(status: ChatStatus) => void>();
+
+function publish(next: Promise) {
+ pending = next;
+ next.then((s) => listeners.forEach((listener) => listener(s)));
+}
+
+/** Use a status the server just returned (e.g. from entering the code). */
+export function setChatStatus(status: ChatStatus) {
+ publish(Promise.resolve(status));
+}
+
+/** Check again, e.g. after a request was refused because the code changed. */
+export function refreshChatStatus() {
+ publish(fetchChatStatus());
+}
/** Whether the backend has chat turned on; null while checking. */
export function useChatStatus(): ChatStatus | null {
const [status, setStatus] = useState(null);
useEffect(() => {
let cancelled = false;
+ listeners.add(setStatus);
pending ??= fetchChatStatus();
pending.then((s) => !cancelled && setStatus(s));
return () => {
cancelled = true;
+ listeners.delete(setStatus);
};
}, []);
return status;
diff --git a/frontend/src/components/HeaderMenu/index.tsx b/frontend/src/components/HeaderMenu/index.tsx
index c609ddde..3a066f8e 100644
--- a/frontend/src/components/HeaderMenu/index.tsx
+++ b/frontend/src/components/HeaderMenu/index.tsx
@@ -259,8 +259,10 @@ export default function HeaderMenu() {
return () => window.removeEventListener('scroll', onScroll);
}, []);
- // Data Chat sits after Data, only when the backend has CHAT_ENABLED set.
- const chatEnabled = useChatStatus()?.enabled ?? false;
+ // Data Chat sits after Data, only when the backend has CHAT_ENABLED set and
+ // this browser has entered the access code; testers go to /chat to enter it.
+ const chatStatus = useChatStatus();
+ const chatEnabled = !!(chatStatus?.enabled && chatStatus.unlocked);
const navLinks: NavItem[] = chatEnabled
? [...links.slice(0, 3), { link: '/chat', label: 'Ask' }, ...links.slice(3)]
: links;
diff --git a/justfile b/justfile
index ca3d1fea..228d8c22 100644
--- a/justfile
+++ b/justfile
@@ -12,7 +12,7 @@ mcp_port := env("MCP_PORT", "6768")
# Pass named variables without placing bearer-token values in podman arguments.
mcp_container_env := "-e MCP_ENABLED -e MCP_AUTH_MODE -e MCP_BEARER_TOKENS -e MCP_ALLOWED_HOSTS -e MCP_ALLOWED_ORIGINS -e MCP_MAX_ROWS -e MCP_MAX_BYTES -e MCP_QUERY_TIMEOUT -e MCP_MAX_CONCURRENCY -e MCP_RATE_LIMIT -e MCP_MAX_REQUEST_BYTES -e MCP_REQUEST_BODY_TIMEOUT"
# Data chat (docs/architecture/data-chat.md); the provider key is passed by name.
-chat_container_env := "-e CHAT_ENABLED -e CHAT_MODEL -e OPENROUTER_API_KEY -e OPENAI_API_KEY -e CHAT_MAX_MODEL_REQUESTS -e CHAT_MAX_TOOL_CALLS -e CHAT_MAX_OUTPUT_TOKENS -e CHAT_PREVIEW_ROWS -e CHAT_MAX_HISTORY_MESSAGES -e CHAT_MAX_MESSAGE_CHARS -e CHAT_MAX_HISTORY_CHARS -e CHAT_RATE_LIMIT -e CHAT_CHART_MAX_ROWS"
+chat_container_env := "-e CHAT_ENABLED -e CHAT_ACCESS_CODE -e CHAT_ACCESS_DAYS -e CHAT_MODEL -e OPENROUTER_API_KEY -e OPENAI_API_KEY -e CHAT_MAX_MODEL_REQUESTS -e CHAT_MAX_TOOL_CALLS -e CHAT_MAX_OUTPUT_TOKENS -e CHAT_PREVIEW_ROWS -e CHAT_MAX_HISTORY_MESSAGES -e CHAT_MAX_MESSAGE_CHARS -e CHAT_MAX_HISTORY_CHARS -e CHAT_RATE_LIMIT -e CHAT_CHART_MAX_ROWS"
# Host-specific podman flags. Empty by default (Docker, macOS, rootful podman).
# On the VM with rootless podman, override: just podman_flags="--userns=keep-id:uid=1000,gid=1000"
podman_flags := env("PODMAN_FLAGS", "")