Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,9 +653,11 @@ tick capabilities and corpus entries by checkbox — without hand-writing
these requests; it is read-only for a `users:view`-only caller.

**Anonymous access (off by default).** Set `TPK_ANONYMOUS_ACCESS=1`
(`[server].anonymous_access`) and the login page offers **Continue without
signing in**: an unauthenticated visitor can chat over the corpus entries whose
visibility is `public` — and nothing else. The anonymous principal holds only
(`[server].anonymous_access`) and a visitor with no session lands straight in
Chat as the anonymous user — no login page first; **Sign in** is in the
sidebar, and the login page offers **Continue without signing in** to go back.
An unauthenticated visitor can chat over the corpus entries whose visibility
is `public` — and nothing else. The anonymous principal holds only
the `chat` capability (no Explorer, no source fragments or thinking trace, no
MCP, no management), its corpus scope is the enabled public entries (enforced
per entry, server-side, on every turn), and the agent's prompt lists only
Expand Down
19 changes: 16 additions & 3 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export default function App() {
// guards the one render before the mount-time /auth/me check resolves, so
// a logged-in reload doesn't flash the login form.
const [me, setMe] = useState<Me | null>(null);
// Server allows unauthenticated chat over the public corpus (#94).
// Server allows unauthenticated chat over the public corpus (#94). With it
// on, "no session" IS a session: a visitor lands straight in Chat as the
// anonymous principal (the mount-time /auth/me check adopts it). The login
// page is then only reached on purpose -- Sign in from the sidebar, or
// signing out of a real account -- both of which clear `me`.
const [anonymousAvailable, setAnonymousAvailable] = useState(false);
const [pendingChangeUser, setPendingChangeUser] = useState<string | null>(null);
const [checked, setChecked] = useState(false);
Expand Down Expand Up @@ -78,7 +82,11 @@ export default function App() {
if (cancelled) return;
if (resp.ok) {
const body = await resp.json();
if (body.anonymous) { setAnonymousAvailable(true); }
if (body.anonymous) {
setAnonymousAvailable(true);
setMe({ username: body.username, role: body.role,
capabilities: body.capabilities ?? [], anonymous: true });
}
// A must_change_password answer routes straight to Login's change
// mode (skipping the login form — we already hold a valid token).
else if (body.must_change_password) setPendingChangeUser(body.username);
Expand Down Expand Up @@ -122,6 +130,10 @@ export default function App() {
}
}

function anonymousMe(): Me {
return { username: "anonymous", role: "anonymous", capabilities: ["chat"], anonymous: true };
}

if (!checked) return null;

if (pendingChangeUser !== null) {
Expand All @@ -136,7 +148,8 @@ export default function App() {
if (!me) {
return (
<div className="shell">
<Login onDone={setMe} anonymousAvailable={anonymousAvailable} />
<Login onDone={setMe} anonymousAvailable={anonymousAvailable}
onContinueAnonymously={anonymousAvailable ? () => setMe(anonymousMe()) : undefined} />
</div>
);
}
Expand Down
12 changes: 5 additions & 7 deletions web/src/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ async function fetchMe(): Promise<Me> {
export default function Login({
onDone,
anonymousAvailable = false,
onContinueAnonymously,
initialMode = "login",
initialUsername = "",
}: {
onDone: (me: Me) => void;
// The server serves unauthenticated chat over the public corpus (#94).
anonymousAvailable?: boolean;
// "Continue without signing in" -> back to the anonymous chat session.
onContinueAnonymously?: () => void;
initialMode?: "login" | "change";
initialUsername?: string;
}) {
Expand Down Expand Up @@ -133,14 +136,9 @@ export default function Login({
<button type="submit" className="tk-btn login-submit" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</button>
{anonymousAvailable && (
{anonymousAvailable && onContinueAnonymously && (
<button type="button" className="tk-btn tk-btn-secondary login-anonymous" disabled={busy}
onClick={async () => {
setBusy(true); setError("");
try { onDone(await fetchMe()); }
catch (e) { setError(String(e)); }
finally { setBusy(false); }
}}>
onClick={onContinueAnonymously}>
Continue without signing in
<span className="login-anonymous-sub">chat over the public docs only</span>
</button>
Expand Down
Loading