diff --git a/README.md b/README.md index db166694..e7fd28ca 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ prints only the configured server URL on success; progress/errors use stderr. `- terminal line, or plain lines when redirected. Failed uploads remain private, immutable pending submissions; `sync --retry` recovers them without the checkout. Cancelling a wait does not cancel an accepted server job. +After the destination account has been verified, rotating its API token preserves +pending-upload recovery. Captures made before any successful authentication stay +bound to their original credential until the account is verified. `diffx .` and piped diffs always run locally, even with remote config. Sync and plugins require a remote URL and bearer token; they never bootstrap a local @@ -175,13 +178,13 @@ mise run docker:down Compose publishes HTTP on `127.0.0.1:7980`, outside the local 7981–7990 range, and preserves the named SQLite volume when stopped or recreated. Set `DIFFX_SERVER_PORT=7979` to publish another host port. The project is named -`diffx`, so its volume is shared by every checkout. Retrieve -the initial credential with `docker compose cp server:/data/state.db.admin-token ./admin-token`. -To choose the credential instead, put `DIFFX_SERVER_TOKEN=…` in a git-ignored -`.env` beside `compose.yaml` before the first start. Any non-empty token works; -prefer a long random one (`openssl rand -hex 32`) when exposing the server. -It only applies to a new database; a different token for an existing account -fails startup. +`diffx`, so its volume is shared by every checkout. Before first start, put +`DIFFX_SERVER_USERNAME=alice` and `DIFFX_SERVER_PASSWORD=…` in a git-ignored +`.env` beside `compose.yaml`. No default account/password is generated. These +bootstrap credentials create the first account once; restarts preserve password +changes. Signup is disabled by default. Compose explicitly enables insecure +cookies for loopback HTTP development; set `DIFFX_SERVER_INSECURE_COOKIES=false` +and deploy behind HTTPS for remote access. See [server development](docs/development.md#server-development) for configuration and persistence details. Deploy behind HTTPS when exposing the server remotely. @@ -190,12 +193,13 @@ Or build and run the image directly: ```sh mise run docker:build docker run --name diffx-server --read-only -p 127.0.0.1:7980:7980 \ + --env-file /path/to/private-server.env \ --mount source=diffx-data,target=/data diffx-server:local ``` -The volume contains observations, jobs and credentials. First -startup writes the admin token to `/data/state.db.admin-token`; copy it with -`docker cp diffx-server:/data/state.db.admin-token ./admin-token`. +The env file supplies bootstrap username/password; plain HTTP development also +requires `DIFFX_SERVER_INSECURE_COOKIES=true`. The volume contains observations, +jobs, accounts and credential hashes. The image runs without Git or a repository mount, as an unprivileged user. SIGTERM drains HTTP and stops the worker; durable unfinished jobs recover after their leases expire. Back up the database while stopped. @@ -204,22 +208,30 @@ Build/install the remote server with Go: ```sh go install github.com/flexdinesh/diffx/cmd/diffx-server@main -DIFFX_SERVER_HOST=127.0.0.1 DIFFX_SERVER_PORT=7980 DIFFX_SERVER_STATE=./test-data/state.db diffx-server +DIFFX_SERVER_USERNAME=alice DIFFX_SERVER_PASSWORD=… \ + DIFFX_SERVER_INSECURE_COOKIES=true DIFFX_SERVER_STATE=./test-data/state.db diffx-server ``` -First startup creates `admin` with a generated token saved privately at -`.admin-token`; startup prints its path. The token persists across -restarts. API/MCP requests use bearer authentication; the browser uses HTTP Basic -with the username and token as password. Deploy behind TLS. Multiple individual -users share one SQLite database; credentials select and isolate each user's data. -The database stores credential hashes. To add a user, stop the server, run: +Sign in with username/password, then open the top-right account settings to create +a named API token. `collector` tokens support sync and plugins; `api` tokens also +support the user's review REST/MCP APIs. Both expire after 90 days by default +(selectable 1–365 days), can be revoked, and show their secret only once. Browser +sessions use secure cookies; API/MCP and collectors use bearer tokens. Multiple +users share one SQLite database with private data per account. Local `diffx` +automatically supplies its owner and has no login or account settings. + +Set `DIFFX_SERVER_SIGNUP_ENABLED=true` to allow signup; invitations are not yet +supported. For operator provisioning or password recovery, stop the server, run: ```sh -diffx-server user create --name alice --state /data/state.db +diffx-server user create --username bob --state /data/state.db --password-stdin < /path/to/private-password +diffx-server user reset-password --username alice --state /data/state.db --password-stdin < /path/to/private-password ``` -Save the printed token, then restart the server. `--account` / `DIFFX_SERVER_ACCOUNT` and `DIFFX_SERVER_TOKEN` -can supply initial bootstrap credentials; existing credentials are never replaced. +Then restart the server. Password reset revokes browser sessions; API token +ownership is independent of passwords. Bootstrap requires both +`DIFFX_SERVER_USERNAME` and `DIFFX_SERVER_PASSWORD`; it never overwrites an existing +account's password. Deploy behind TLS with secure cookies. Use `--retention-days 14` or config/environment to change server retention. The server defaults to `127.0.0.1:7980` and `./data/state.db`, independent of personal CLI settings. Its image defaults to `0.0.0.0:7980` and `/data/state.db`; @@ -310,7 +322,7 @@ WebMCP while the page is open; see [docs/webmcp.md](docs/webmcp.md). Local data lives in `$XDG_STATE_HOME/diffx/state.db`, or `~/.local/state/diffx/state.db`. `--state memory` disables persistence. -Current schema 9 state persists across restarts. Older, newer or unrecognized +Current schema 10 state persists across restarts. Older, newer or unrecognized databases are refused without an automatic reset. To start fresh, stop the server, back up its state directory, and explicitly choose a new database path. Legacy commands and pending producer payloads are retired. diff --git a/apps/web/index.html b/apps/web/index.html index c22f9b42..18984e06 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -20,7 +20,7 @@ document.documentElement.dataset.theme = theme; })(); - diffx · Local diff + diffx · Code review
diff --git a/apps/web/src/Header.tsx b/apps/web/src/Header.tsx index 24b2fed1..6a91a4f2 100644 --- a/apps/web/src/Header.tsx +++ b/apps/web/src/Header.tsx @@ -13,6 +13,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { AccountButton } from "./account.tsx"; import { DeleteContextButton } from "./DeleteContextButton.tsx"; import { ProjectsToggle } from "./ProjectsNav.tsx"; import { useDiffSource } from "./app-state.tsx"; @@ -143,6 +144,7 @@ export function Header() { + ); diff --git a/apps/web/src/account-tokens.tsx b/apps/web/src/account-tokens.tsx new file mode 100644 index 00000000..3cb79e05 --- /dev/null +++ b/apps/web/src/account-tokens.tsx @@ -0,0 +1,259 @@ +import { + api, + errorDetail, + readAccountTokens, + readCreatedToken, + type ApiAccountToken, +} from "@diffx/api"; +import { useEffect, useState, type FormEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +function date(value: number) { + return new Date(value).toLocaleDateString(); +} + +export function AccountTokens() { + const [tokens, setTokens] = useState([]); + const [loaded, setLoaded] = useState(false); + const [attempt, setAttempt] = useState(0); + const [name, setName] = useState(""); + const [purpose, setPurpose] = useState<"collector" | "api">("collector"); + const [expiresInDays, setExpiry] = useState<30 | 90 | 365>(90); + const [created, setCreated] = useState<{ + token: ApiAccountToken; + secret: string; + } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [copyStatus, setCopyStatus] = useState(""); + useEffect(() => { + const controller = new AbortController(); + setError(""); + void api + .GET("/api/v2/account/tokens", { signal: controller.signal }) + .then(({ data, error }) => { + if (controller.signal.aborted) return; + if (!data) + throw new Error( + errorDetail(error, "Unable to load API tokens. Try again."), + ); + setTokens(readAccountTokens(data)); + setLoaded(true); + }) + .catch((failure: unknown) => { + if (!controller.signal.aborted) + setError( + errorDetail(failure, "Unable to load API tokens. Try again."), + ); + }); + return () => controller.abort(); + }, [attempt]); + async function create(event: FormEvent) { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(""); + setCreated(null); + setCopyStatus(""); + try { + const { data, error } = await api.POST("/api/v2/account/tokens", { + body: { name, purpose, expiresInDays }, + }); + if (!data) + throw new Error( + errorDetail(error, "Unable to create token. Try again."), + ); + const created = readCreatedToken(data); + setCreated(created); + setTokens((value) => [...value, created.token]); + setName(""); + } catch (failure: unknown) { + setError(errorDetail(failure, "Unable to create token. Try again.")); + } finally { + setBusy(false); + } + } + async function revoke(id: string) { + setBusy(true); + setError(""); + try { + const { response, error } = await api.DELETE( + "/api/v2/account/tokens/{tokenId}", + { params: { path: { tokenId: id } } }, + ); + if (!response.ok) + throw new Error( + errorDetail(error, "Unable to revoke token. Try again."), + ); + setTokens((value) => value.filter((token) => token.id !== id)); + if (created?.token.id === id) setCreated(null); + } catch (failure: unknown) { + setError(errorDetail(failure, "Unable to revoke token. Try again.")); + } finally { + setBusy(false); + } + } + const configuration = created + ? JSON.stringify( + { remote: { url: window.location.origin, token: created.secret } }, + null, + 2, + ) + : ""; + async function copy() { + try { + await navigator.clipboard.writeText( + created?.token.purpose === "collector" + ? configuration + : (created?.secret ?? ""), + ); + setCopyStatus("Copied"); + } catch { + setCopyStatus("Copy unavailable. Select and copy the text below."); + } + } + return ( +
+

API tokens

+

+ Connect collectors or review API clients to your account. Secrets appear + once. +

+ {error && ( +

+ {error} +

+ )} + {!loaded && !error &&

Loading tokens…

} + {!loaded && error && ( + + )} + {loaded && ( + <> + {tokens.length === 0 ? ( +

+ No tokens yet. Create a collector token to publish your first + review. +

+ ) : ( +
    + {tokens.map((token) => ( +
  • +
    + {token.name} + + {token.purpose === "collector" + ? "Collector" + : "Review API"}{" "} + · Created {date(token.createdAt)} ·{" "} + {token.expiresAt <= Date.now() ? "Expired" : "Expires"}{" "} + {date(token.expiresAt)} + + + {token.lastUsedAt === null + ? "Never used" + : `Last used ${date(token.lastUsedAt)}`} + +
    + +
  • + ))} +
+ )} +
+ + setName(event.target.value)} + disabled={busy} + /> + + +

+ {purpose === "collector" + ? "Upload changes and check their ingestion status." + : "Read and update your reviews through the API or MCP."} +

+ + + +
+ + )} + {created && ( +
+

Save your token now

+

This secret will disappear when you leave this page.

+ {created.token.purpose === "collector" && ( +

+ Merge this remote section into your diffx JSON config, then run{" "} + diffx sync. +

+ )} +
+            {created.token.purpose === "collector"
+              ? configuration
+              : created.secret}
+          
+
+ + +
+ {copyStatus &&

{copyStatus}

} +
+ )} +
+ ); +} diff --git a/apps/web/src/account.css b/apps/web/src/account.css new file mode 100644 index 00000000..31d64137 --- /dev/null +++ b/apps/web/src/account.css @@ -0,0 +1,136 @@ +.identity-status { + height: 100dvh; +} +.account-shell { + height: 100dvh; + display: flex; + flex-direction: column; +} +.account-topbar { + flex-shrink: 0; +} +.account-page { + width: 100%; + max-width: 48rem; + margin-inline: auto; + padding: var(--space-8) var(--space-6); + overflow: auto; + min-height: 0; +} +.auth-page { + max-width: 28rem; + height: 100dvh; + padding-top: var(--space-16); +} +.account-page h1 { + font-size: var(--text-xl); + font-weight: var(--weight-semibold); + margin: 0 0 var(--space-2); +} +.auth-page .brand { + display: inline-block; + margin-bottom: var(--space-8); +} +.account-page h2, +.account-page h3 { + font-size: var(--text-md); + font-weight: var(--weight-semibold); + margin: 0 0 var(--space-2); +} +.account-page p { + margin: 0 0 var(--space-4); + color: var(--text-secondary); + line-height: var(--leading-copy); +} +.account-section { + margin-top: var(--space-8); + padding-top: var(--space-6); + border-top: 1px solid var(--border-muted); +} +.account-form { + display: grid; + gap: var(--space-2); + max-width: 28rem; +} +.account-form label:not(:first-child) { + margin-top: var(--space-2); +} +.account-form button { + justify-self: start; + margin-top: var(--space-2); +} +.account-form select { + min-height: var(--control-height); + border: 1px solid var(--border-control); + border-radius: var(--radius-control); + padding: var(--space-1) var(--space-2); + background: var(--bg); + color: var(--fg); +} +.account-page .account-hint { + font-size: var(--text-sm); + margin: 0; +} +.account-page .account-error { + color: var(--error); +} +.account-token-list { + list-style: none; + padding: 0; + margin: 0 0 var(--space-6); +} +.account-token-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding-block: var(--space-3); + border-bottom: 1px solid var(--border-muted); +} +.account-token-list li div { + min-width: 0; + overflow-wrap: anywhere; +} +.account-token-list strong { + display: block; + font-weight: var(--weight-medium); +} +.account-token-list span { + display: block; + color: var(--muted); + font-size: var(--text-sm); + margin-top: var(--space-1); +} +.token-secret { + margin-top: var(--space-6); + padding: var(--space-4); + background: var(--panel); + border: 1px solid var(--border-muted); + border-radius: var(--radius-control); +} +.token-secret pre { + font-family: var(--font-mono); + font-size: var(--text-sm); + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: all; + margin: var(--space-4) 0; +} +.account-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +@media (max-width: 640px), (pointer: coarse) { + .account-page { + padding: var(--space-6) var(--space-4); + } + .account-form input, + .account-form select { + min-height: var(--touch-target); + font-size: var(--text-input-touch); + } + .account-page button { + min-height: var(--touch-target); + } +} diff --git a/apps/web/src/account.tsx b/apps/web/src/account.tsx new file mode 100644 index 00000000..b6b50c30 --- /dev/null +++ b/apps/web/src/account.tsx @@ -0,0 +1,256 @@ +import { api, errorDetail, readBootstrap } from "@diffx/api"; +import { ArrowLeftIcon, UserRoundIcon } from "lucide-react"; +import { useState, type FormEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { navigate, useIdentity } from "./identity-context.tsx"; +import { AccountTokens } from "./account-tokens.tsx"; + +export function AccountButton() { + const { state } = useIdentity(); + if ( + state.status !== "ready" || + state.bootstrap.mode !== "remote" || + !state.bootstrap.user + ) + return null; + return ( + + ); +} + +export function SignIn() { + const { state, establish } = useIdentity(); + const [signup, setSignup] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const signupEnabled = + state.status === "ready" && state.bootstrap.signupEnabled; + async function submit(event: FormEvent) { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(""); + try { + const { data, error } = await api.POST( + signup ? "/api/v2/auth/signup" : "/api/v2/auth/login", + { body: { username, password } }, + ); + if (!data) + throw new Error(errorDetail(error, "Unable to sign in. Try again.")); + const bootstrap = readBootstrap(data); + if ( + bootstrap.mode !== "remote" || + !bootstrap.user || + !bootstrap.csrfToken + ) + throw new Error( + "Server did not establish a browser session. Try again.", + ); + establish(bootstrap); + setPassword(""); + } catch (failure: unknown) { + setError(errorDetail(failure, "Unable to sign in. Try again.")); + } finally { + setBusy(false); + } + } + return ( +
+ + diffx + +

{signup ? "Create your account" : "Sign in"}

+

Review your collected changes on this server.

+
+ + setUsername(event.target.value)} + required + minLength={3} + maxLength={32} + disabled={busy} + /> + + setPassword(event.target.value)} + required + minLength={signup ? 12 : undefined} + maxLength={128} + disabled={busy} + /> + {signup &&

Use at least 12 characters.

} + {error && ( +

+ {error} +

+ )} + +
+ {signupEnabled && ( + + )} +
+ ); +} + +export function AccountSettings() { + const { state, signedOut } = useIdentity(); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + if (state.status !== "ready" || !state.bootstrap.user) return null; + async function logout() { + setBusy(true); + setError(""); + try { + const { response, error } = await api.POST("/api/v2/auth/logout"); + if (!response.ok) + throw new Error(errorDetail(error, "Unable to sign out. Try again.")); + signedOut(); + } catch (failure: unknown) { + setError(errorDetail(failure, "Unable to sign out. Try again.")); + } finally { + setBusy(false); + } + } + return ( +
+
+ + {state.bootstrap.user.username} +
+
+

Account settings

+

+ Signed in as {state.bootstrap.user.username}. +

+ + +
+

Browser session

+ {error && ( +

+ {error} +

+ )} + +
+
+
+ ); +} + +function PasswordSettings() { + const { signedOut } = useIdentity(); + const [currentPassword, setCurrent] = useState(""); + const [newPassword, setNew] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + async function submit(event: FormEvent) { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(""); + try { + const { response, error } = await api.PUT("/api/v2/account/password", { + body: { currentPassword, newPassword }, + }); + if (!response.ok) + throw new Error( + errorDetail(error, "Unable to change password. Try again."), + ); + signedOut(); + } catch (failure: unknown) { + setError(errorDetail(failure, "Unable to change password. Try again.")); + } finally { + setBusy(false); + } + } + return ( +
+

Password

+

+ Changing your password signs out every browser session. API tokens + remain active. +

+
+ + setCurrent(event.target.value)} + required + disabled={busy} + /> + + setNew(event.target.value)} + required + disabled={busy} + /> +

Use at least 12 characters.

+ {error && ( +

+ {error} +

+ )} + +
+
+ ); +} diff --git a/apps/web/src/identity-context.tsx b/apps/web/src/identity-context.tsx new file mode 100644 index 00000000..7e29fd4e --- /dev/null +++ b/apps/web/src/identity-context.tsx @@ -0,0 +1,141 @@ +import { + api, + errorDetail, + readBootstrap, + type ApiBootstrap, + type ApiMiddleware, +} from "@diffx/api"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Button } from "@/components/ui/button"; + +type IdentityState = + | { status: "loading" } + | { status: "error"; detail: string } + | { status: "ready"; bootstrap: ApiBootstrap }; +interface Identity { + state: IdentityState; + reload: () => void; + establish: (value: unknown) => void; + signedOut: () => void; +} +const IdentityContext = createContext(null); + +export function navigate(path: string) { + window.history.pushState(null, "", path); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function IdentityProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ status: "loading" }); + const [attempt, setAttempt] = useState(0); + const current = useRef(null); + const owner = useRef(new AbortController()); + const establish = useCallback((value: unknown) => { + const bootstrap = readBootstrap(value); + if (current.current && current.current.mode !== bootstrap.mode) + throw new Error("Server deployment mode changed. Reload this page."); + owner.current.abort(); + owner.current = new AbortController(); + current.current = bootstrap; + setState({ status: "ready", bootstrap }); + }, []); + const signedOut = useCallback(() => { + const bootstrap = current.current; + if (!bootstrap || bootstrap.mode !== "remote") return; + establish({ ...bootstrap, user: null, csrfToken: null }); + window.history.replaceState(null, "", "/"); + window.dispatchEvent(new PopStateEvent("popstate")); + }, [establish]); + useEffect(() => { + const middleware: ApiMiddleware = { + onRequest({ request }) { + const path = new URL(request.url).pathname; + if ( + path === "/api/v2/bootstrap" || + path === "/api/v2/auth/login" || + path === "/api/v2/auth/signup" + ) + return request; + const csrf = current.current?.csrfToken; + if (csrf && request.method !== "GET" && request.method !== "HEAD") + request.headers.set("X-CSRF-Token", csrf); + return new Request(request, { + signal: AbortSignal.any([request.signal, owner.current.signal]), + }); + }, + onResponse({ request, response }) { + if ( + response.status === 401 && + !request.signal.aborted && + new URL(request.url).pathname !== "/api/v2/auth/login" && + new URL(request.url).pathname !== "/api/v2/auth/signup" + ) + signedOut(); + }, + }; + api.use(middleware); + return () => { + api.eject(middleware); + owner.current.abort(); + }; + }, [signedOut]); + useEffect(() => { + const controller = new AbortController(); + setState({ status: "loading" }); + void api + .GET("/api/v2/bootstrap", { signal: controller.signal }) + .then(({ data, error }) => { + if (controller.signal.aborted) return; + if (!data) + throw new Error(errorDetail(error, "Unable to load server identity")); + establish(data); + }) + .catch((error: unknown) => { + if (!controller.signal.aborted) + setState({ + status: "error", + detail: errorDetail(error, "Unable to load server identity"), + }); + }); + return () => controller.abort(); + }, [attempt, establish]); + const reload = useCallback(() => setAttempt((value) => value + 1), []); + return ( + + {children} + + ); +} + +export function useIdentity() { + const identity = useContext(IdentityContext); + if (!identity) throw new Error("Identity consumers require IdentityProvider"); + return identity; +} + +export function IdentityStatus() { + const { state, reload } = useIdentity(); + return ( +
+

+ {state.status === "error" ? "Cannot load diffx" : "Loading diffx"} +

+

+ {state.status === "error" ? state.detail : "Connecting to your server…"} +

+ {state.status === "error" && ( + + )} +
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b2a431ba..6b18dc16 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,5 +1,5 @@ import { WorkerPoolContextProvider } from "@pierre/diffs/react"; -import { StrictMode } from "react"; +import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App.tsx"; import { AppearanceProvider } from "./appearance-context.tsx"; @@ -13,26 +13,62 @@ import "./typography.css"; import "./tokens.css"; import "./style.css"; import "./review.css"; +import "./account.css"; +import { + IdentityProvider, + IdentityStatus, + useIdentity, +} from "./identity-context.tsx"; +import { SignIn, AccountSettings } from "./account.tsx"; const root = document.getElementById("root"); if (!root) throw new Error("Missing React root"); +function ApplicationRoutes() { + const { state } = useIdentity(); + const [path, setPath] = useState(window.location.pathname); + useEffect(() => { + const changed = () => setPath(window.location.pathname); + window.addEventListener("popstate", changed); + return () => window.removeEventListener("popstate", changed); + }, []); + const localSettings = + state.status === "ready" && + state.bootstrap.mode === "local" && + path === "/settings"; + useEffect(() => { + if (!localSettings) return; + window.history.replaceState(null, "", "/"); + setPath("/"); + }, [localSettings]); + if (state.status !== "ready") return ; + if (state.bootstrap.mode === "remote" && !state.bootstrap.user) + return ; + if (path === "/settings" && state.bootstrap.mode === "remote") + return ; + return ( + + + + + + + + + + ); +} + createRoot(root).render( - - - - - - - - - - - + + + + + , ); diff --git a/apps/web/src/session-context.tsx b/apps/web/src/session-context.tsx index 124b759b..4b9571cb 100644 --- a/apps/web/src/session-context.tsx +++ b/apps/web/src/session-context.tsx @@ -10,6 +10,8 @@ import { useState, type RefObject, } from "react"; +import { AccountButton } from "./account.tsx"; +import { useIdentity, navigate } from "./identity-context.tsx"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { ProjectPicker, ProjectPickerTrigger } from "./ProjectPicker.tsx"; @@ -73,6 +75,9 @@ export function ContextSwitcher() { } export function SessionProvider({ children }: { children: ReactNode }) { + const { state: identity } = useIdentity(); + const remote = + identity.status === "ready" && identity.bootstrap.mode === "remote"; const [selectedId, setSelectedId] = useState(contextFromUrl); const [state, setState] = useState({ status: "loading" }); const [attempt, setAttempt] = useState(0); @@ -214,6 +219,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { +
+ +

{empty - ? "Run diffx to review a checkout, or diffx sync to publish remotely." + ? remote + ? "Create a collector token in settings, then run diffx sync to publish your changes." + : "Run diffx to review a checkout, or diffx sync to publish remotely." : state.status === "error" ? state.detail : heads.error || "Reading review context…"}

+ {empty && remote && ( + + )} {(state.status === "error" || heads.error) && (