* feat(authz): gate composer send on runs:create (Phase 4 follow-up, #4063)
Mirrors #5294's stop gating on the send side: both chat routes pass
canCreateRuns (from PERMISSIONS.RUNS_CREATE, lockstep with the backend
enum) into the shared composer. The gate sits at the top of
submitThreadMessage — the single choke point every composer entry
(submit button, Enter, goal-set-triggered run) funnels through — and
denies with a toast plus a rejected promise so PromptInput keeps the
text. The idle submit button is disabled and explains the boundary via
conditionally-spread aria-label/title (startTurnUnavailable, en/zh);
while streaming the button stays the runs:cancel stop affordance.
Also removes the unreachable kind === "stop" branch in handleSubmit
(flagged during #5294's review): the Enter path early-returns with the
streaming toast before the classifier runs.
* fix(frontend): reject denied goal starts before saving state
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(authz): gate thread-delete and run-cancel UI on effective permissions (Phase 4, #4063)
Consume the effective route permissions surfaced by #5228 so the UI hides
actions the caller's role cannot perform:
- threads:delete hides the sidebar thread-row Delete menu item and the
sidecar panel delete button (every useDeleteThread consumer)
- runs:cancel disables the composer stop affordance; all three stop entry
points converge on one check inside handleStopStreaming
hasPermission treats an absent/null/unresolved permission list as
permissive, so a mixed old-backend/new-frontend deploy never hides actions
the caller can still perform. The Gateway @require_permission guards
remain the single enforcement point.
* fix(authz): review follow-ups for stop gating (comment accuracy, a11y, tests)
- Correct the defense-in-depth comment: the submit-button click is the
only live entry into handleStopStreaming (handleSubmit returns early
with the pleaseWaitStreaming toast while streaming, so the kind==="stop"
branch is unreachable); the handler gate stays as future-proofing.
- Explain the disabled stop affordance with aria-label + title (Radix
tooltips don't fire on disabled buttons), with en-US/zh-CN strings.
- Add the composer stop-gating DOM tests (disabled + onStop never fires +
permissive default) and the sidebar delete-menu gating tests, so all
gated surfaces carry wiring tests.
* fix(authz): stop conditional aria-label from stripping the submit name
The stop-gating follow-up (1612855b) explained the disabled stop
affordance with aria-label/title but passed explicitly-undefined
values in the non-denied case. PromptInputSubmit declares its default
aria-label="Submit" before {...props}, so the undefined key landed in
the spread and clobbered the default: React omits the attribute
entirely and the submit control lost its accessible name in every
state, which broke the sidecar e2e layout helper (it locates the
button by its "Submit" label).
Spread the attributes conditionally so they only attach when
stopDenied, and lock the invariant with a DOM test asserting the base
"Submit" name survives when stop is not denied (mutation-verified:
reverting the conditional spread turns the new test red).
* test(authz): drop unused rerenderWith helper, guard accessible name by role query
Address the review nit on the stop-gating DOM tests: the
rerenderWith helper was never called, and a second render() would
append a composer instead of updating the first one anyway — drop it
(the sidecar-delete-gating tests already demonstrate the correct
rerender pattern if a granted->denied flip test is ever needed).
Also resolve the accessible-name regression guard through
getByRole("button", { name: "Submit" }) so it fails exactly the way
e2e and assistive tech consume the control (mutation-verified: the
explicitly-undefined aria-label form turns it red).
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(auth): recover from setup status timeouts
* test(auth): cover setup status recovery flows
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(auth): let deployments close local self-registration
The OIDC provisioning policy (allowed_email_domains, require_verified_email,
auto_create_users) is enforced only in the SSO callback via
get_or_provision_oidc_user. POST /api/v1/auth/register creates a local account
without consulting any of it, and nothing can turn that path off, so a
deployment declaring an email-domain allowlist can still be joined by any
address through local registration.
Add auth.local.allow_registration (default true, so existing deployments are
unchanged) and gate /register on it before the account is created. Report the
flag from /setup-status so the login page stops offering a signup entry the
Gateway will reject.
/initialize is deliberately not gated: it is the bootstrap path, guarded by
admin_count == 0, and closing it would leave a fresh install unable to create
its first admin.
An unreadable config.yaml falls back to the pre-gate default (open) rather than
making these two endpoints a hard dependency on the file.
* docs(auth): align registration-gate fallback wording with the FileNotFoundError catch
* fix(frontend): keep workspace interactive when SSR auth probe cannot reach gateway (#3493)
When the SSR auth probe at /api/v1/auth/me times out or fails, the
workspace layout used to render a static fallback page without
AuthProvider or QueryClientProvider, making logout and every other
interaction non-functional until the gateway recovered.
Render the normal WorkspaceContent in 'gateway_unavailable' mode
instead, surfacing a polite offline banner that re-probes the gateway
in the background and hides itself the moment refreshUser() returns
an authenticated user. The probe is reentrancy-guarded so a slow
gateway cannot pile up parallel /auth/me requests.
Closes#3493
* fix(workspace): silent probe in offline banner to avoid /login redirect during gateway recovery (#3493)
The banner previously delegated retry probes to AuthProvider.refreshUser(),
which treats any 401 from /api/v1/auth/me as 'session expired' and
force-redirects to /login. During gateway recovery, the first few requests
may transiently return 401 before the gateway is fully ready, which would
incorrectly kick the user out — defeating the purpose of the offline banner.
Now the banner silently fetches /api/v1/auth/me itself and only delegates
to refreshUser() on 200 OK. Non-200 responses (401 / 5xx / network) are
swallowed and retried on the next interval tick, ensuring the user stays
logged in across short gateway outages.
Verified in Docker:
- docker pause deer-flow-gateway → banner appears, page interactive
- docker unpause deer-flow-gateway → banner auto-disappears within 10s,
user remains on /workspace/chats/new with full session restored
- All 117 unit tests pass
* fix(workspace): fix banner polling leak and persistent 401 handling (#3493)
- Stop polling immediately after user recovery: add user to effect dependencies, cleanup interval when user !== null
- Handle persistent 401: trigger login redirect after 3 consecutive unauthorized responses
- Extract decision logic to pure helper, add 8 unit tests covering all critical paths
* fix(workspace): address CR feedback on gateway offline recovery (#3493)
- gateway-offline-banner-helpers: decrement (not reset) auth-failure
streak on transient outcomes so a flapping gateway (401 alternating
with 5xx) still converges on session-expired
- gateway-offline-banner: reuse probe response body to apply user
directly via new AuthProvider.applyUser, halving the recovery burst
against an already-struggling gateway
- gateway-offline-banner: extract classifyProbe into helpers for unit
testability; log probe failures via console.warn instead of swallowing
- gateway-offline-fallback: new shared component used by both workspace
and (auth) layouts so auth pages recover the same way the workspace
does, fixing the lockup where bare static HTML had no AuthProvider
- AuthProvider.logout: fall back to hard navigation when the gateway
logout fetch fails, matching legacy form-POST behaviour and avoiding
stale client state during outage
- tests: extend gateway-offline-banner-helpers.test with flapping
convergence and classifyProbe branch coverage (19 cases total)
* fix(frontend): unify gateway-config localhost fallback for prod (#2705)
`getGatewayConfig()` only fell back to localhost defaults when
`NODE_ENV === "development"`, while `next.config.js` always falls back
to `127.0.0.1:8001`. Running `make start` (which sets NODE_ENV=production
via `next start`) without `DEER_FLOW_INTERNAL_GATEWAY_BASE_URL` /
`DEER_FLOW_TRUSTED_ORIGINS` therefore caused zod to throw inside SSR
layouts and surfaced as a 500.
Drop the NODE_ENV gating and use localhost defaults everywhere — the
"force explicit config in prod" intent should be enforced by deployment
templates (docker-compose already sets both vars), not by request-time
crashes. Document the two vars in both .env.example files and add unit
coverage for the dev/prod env-unset paths.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Update internalGatewayUrl in gateway config tests
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>