feat(auth): release-validation pass for 2.0-rc — 12 blockers + simplify follow-ups (#2008)
* feat(auth): introduce backend auth module
Port RFC-001 authentication core from PR #1728:
- JWT token handling (create_access_token, decode_token, TokenPayload)
- Password hashing (bcrypt) with verify_password
- SQLite UserRepository with base interface
- Provider Factory pattern (LocalAuthProvider)
- CLI reset_admin tool
- Auth-specific errors (AuthErrorCode, TokenError, AuthErrorResponse)
Deps:
- bcrypt>=4.0.0
- pyjwt>=2.9.0
- email-validator>=2.0.0
- backend/uv.toml pins public PyPI index
Tests: 12 pure unit tests (test_auth_config.py, test_auth_errors.py).
Scope note: authz.py, test_auth.py, and test_auth_type_system.py are
deferred to commit 2 because they depend on middleware and deps wiring
that is not yet in place. Commit 1 stays "pure new files only" as the
spec mandates.
* feat(auth): wire auth end-to-end (middleware + frontend replacement)
Backend:
- Port auth_middleware, csrf_middleware, langgraph_auth, routers/auth
- Port authz decorator (owner_filter_key defaults to 'owner_id')
- Merge app.py: register AuthMiddleware + CSRFMiddleware + CORS, add
_ensure_admin_user lifespan hook, _migrate_orphaned_threads helper,
register auth router
- Merge deps.py: add get_local_provider, get_current_user_from_request,
get_optional_user_from_request; keep get_current_user as thin str|None
adapter for feedback router
- langgraph.json: add auth path pointing to langgraph_auth.py:auth
- Rename metadata['user_id'] -> metadata['owner_id'] in langgraph_auth
(both metadata write and LangGraph filter dict) + test fixtures
Frontend:
- Delete better-auth library and api catch-all route
- Remove better-auth npm dependency and env vars (BETTER_AUTH_SECRET,
BETTER_AUTH_GITHUB_*) from env.js
- Port frontend/src/core/auth/* (AuthProvider, gateway-config,
proxy-policy, server-side getServerSideUser, types)
- Port frontend/src/core/api/fetcher.ts
- Port (auth)/layout, (auth)/login, (auth)/setup pages
- Rewrite workspace/layout.tsx as server component that calls
getServerSideUser and wraps in AuthProvider
- Port workspace/workspace-content.tsx for the client-side sidebar logic
Tests:
- Port 5 auth test files (test_auth, test_auth_middleware,
test_auth_type_system, test_ensure_admin, test_langgraph_auth)
- 176 auth tests PASS
After this commit: login/logout/registration flow works, but persistence
layer does not yet filter by owner_id. Commit 4 closes that gap.
* feat(auth): account settings page + i18n
- Port account-settings-page.tsx (change password, change email, logout)
- Wire into settings-dialog.tsx as new "account" section with UserIcon,
rendered first in the section list
- Add i18n keys:
- en-US/zh-CN: settings.sections.account ("Account" / "账号")
- en-US/zh-CN: button.logout ("Log out" / "退出登录")
- types.ts: matching type declarations
* feat(auth): enforce owner_id across 2.0-rc persistence layer
Add request-scoped contextvar-based owner filtering to threads_meta,
runs, run_events, and feedback repositories. Router code is unchanged
— isolation is enforced at the storage layer so that any caller that
forgets to pass owner_id still gets filtered results, and new routes
cannot accidentally leak data.
Core infrastructure
-------------------
- deerflow/runtime/user_context.py (new):
- ContextVar[CurrentUser | None] with default None
- runtime_checkable CurrentUser Protocol (structural subtype with .id)
- set/reset/get/require helpers
- AUTO sentinel + resolve_owner_id(value, method_name) for sentinel
three-state resolution: AUTO reads contextvar, explicit str
overrides, explicit None bypasses the filter (for migration/CLI)
Repository changes
------------------
- ThreadMetaRepository: create/get/search/update_*/delete gain
owner_id=AUTO kwarg; read paths filter by owner, writes stamp it,
mutations check ownership before applying
- RunRepository: put/get/list_by_thread/delete gain owner_id=AUTO kwarg
- FeedbackRepository: create/get/list_by_run/list_by_thread/delete
gain owner_id=AUTO kwarg
- DbRunEventStore: list_messages/list_events/list_messages_by_run/
count_messages/delete_by_thread/delete_by_run gain owner_id=AUTO
kwarg. Write paths (put/put_batch) read contextvar softly: when a
request-scoped user is available, owner_id is stamped; background
worker writes without a user context pass None which is valid
(orphan row to be bound by migration)
Schema
------
- persistence/models/run_event.py: RunEventRow.owner_id = Mapped[
str | None] = mapped_column(String(64), nullable=True, index=True)
- No alembic migration needed: 2.0 ships fresh, Base.metadata.create_all
picks up the new column automatically
Middleware
----------
- auth_middleware.py: after cookie check, call get_optional_user_from_
request to load the real User, stamp it into request.state.user AND
the contextvar via set_current_user, reset in a try/finally. Public
paths and unauthenticated requests continue without contextvar, and
@require_auth handles the strict 401 path
Test infrastructure
-------------------
- tests/conftest.py: @pytest.fixture(autouse=True) _auto_user_context
sets a default SimpleNamespace(id="test-user-autouse") on every test
unless marked @pytest.mark.no_auto_user. Keeps existing 20+
persistence tests passing without modification
- pyproject.toml [tool.pytest.ini_options]: register no_auto_user
marker so pytest does not emit warnings for opt-out tests
- tests/test_user_context.py: 6 tests covering three-state semantics,
Protocol duck typing, and require/optional APIs
- tests/test_thread_meta_repo.py: one test updated to pass owner_id=
None explicitly where it was previously relying on the old default
Test results
------------
- test_user_context.py: 6 passed
- test_auth*.py + test_langgraph_auth.py + test_ensure_admin.py: 127
- test_run_event_store / test_run_repository / test_thread_meta_repo
/ test_feedback: 92 passed
- Full backend suite: 1905 passed, 2 failed (both @requires_llm flaky
integration tests unrelated to auth), 1 skipped
* feat(auth): extend orphan migration to 2.0-rc persistence tables
_ensure_admin_user now runs a three-step pipeline on every boot:
Step 1 (fatal): admin user exists / is created / password is reset
Step 2 (non-fatal): LangGraph store orphan threads → admin
Step 3 (non-fatal): SQL persistence tables → admin
- threads_meta
- runs
- run_events
- feedback
Each step is idempotent. The fatal/non-fatal split mirrors PR #1728's
original philosophy: admin creation failure blocks startup (the system
is unusable without an admin), whereas migration failures log a warning
and let the service proceed (a partial migration is recoverable; a
missing admin is not).
Key helpers
-----------
- _iter_store_items(store, namespace, *, page_size=500):
async generator that cursor-paginates across LangGraph store pages.
Fixes PR #1728's hardcoded limit=1000 bug that would silently lose
orphans beyond the first page.
- _migrate_orphaned_threads(store, admin_user_id):
Rewritten to use _iter_store_items. Returns the migrated count so the
caller can log it; raises only on unhandled exceptions.
- _migrate_orphan_sql_tables(admin_user_id):
Imports the 4 ORM models lazily, grabs the shared session factory,
runs one UPDATE per table in a single transaction, commits once.
No-op when no persistence backend is configured (in-memory dev).
Tests: test_ensure_admin.py (8 passed)
* test(auth): port AUTH test plan docs + lint/format pass
- Port backend/docs/AUTH_TEST_PLAN.md and AUTH_UPGRADE.md from PR #1728
- Rename metadata.user_id → metadata.owner_id in AUTH_TEST_PLAN.md
(4 occurrences from the original PR doc)
- ruff auto-fix UP037 in sentinel type annotations: drop quotes around
"str | None | _AutoSentinel" now that from __future__ import
annotations makes them implicit string forms
- ruff format: 2 files (app/gateway/app.py, runtime/user_context.py)
Note on test coverage additions:
- conftest.py autouse fixture was already added in commit 4 (had to
be co-located with the repository changes to keep pre-existing
persistence tests passing)
- cross-user isolation E2E tests (test_owner_isolation.py) deferred
— enforcement is already proven by the 98-test repository suite
via the autouse fixture + explicit _AUTO sentinel exercises
- New test cases (TC-API-17..20, TC-ATK-13, TC-MIG-01..07) listed
in AUTH_TEST_PLAN.md are deferred to a follow-up PR — they are
manual-QA test cases rather than pytest code, and the spec-level
coverage is already met by test_user_context.py + the 98-test
repository suite.
Final test results:
- Auth suite (test_auth*, test_langgraph_auth, test_ensure_admin,
test_user_context): 186 passed
- Persistence suite (test_run_event_store, test_run_repository,
test_thread_meta_repo, test_feedback): 98 passed
- Lint: ruff check + ruff format both clean
* test(auth): add cross-user isolation test suite
10 tests exercising the storage-layer owner filter by manually
switching the user_context contextvar between two users. Verifies
the safety invariant:
After a repository write with owner_id=A, a subsequent read with
owner_id=B must not return the row, and vice versa.
Covers all 4 tables that own user-scoped data:
TC-API-17 threads_meta — read, search, update, delete cross-user
TC-API-18 runs — get, list_by_thread, delete cross-user
TC-API-19 run_events — list_messages, list_events, count_messages,
delete_by_thread (CRITICAL: raw conversation
content leak vector)
TC-API-20 feedback — get, list_by_run, delete cross-user
Plus two meta-tests verifying the sentinel pattern itself:
- AUTO + unset contextvar raises RuntimeError
- explicit owner_id=None bypasses the filter (migration escape hatch)
Architecture note
-----------------
These tests bypass the HTTP layer by design. The full chain
(cookie → middleware → contextvar → repository) is covered piecewise:
- test_auth_middleware.py: middleware sets contextvar from cookies
- test_owner_isolation.py: repositories enforce isolation when
contextvar is set to different users
Together they prove the end-to-end safety property without the
ceremony of spinning up a full TestClient + in-memory DB for every
router endpoint.
Tests pass: 231 (full auth + persistence + isolation suite)
Lint: clean
* refactor(auth): migrate user repository to SQLAlchemy ORM
Move the users table into the shared persistence engine so auth
matches the pattern of threads_meta, runs, run_events, and feedback —
one engine, one session factory, one schema init codepath.
New files
---------
- persistence/user/__init__.py, persistence/user/model.py: UserRow
ORM class with partial unique index on (oauth_provider, oauth_id)
- Registered in persistence/models/__init__.py so
Base.metadata.create_all() picks it up
Modified
--------
- auth/repositories/sqlite.py: rewritten as async SQLAlchemy,
identical constructor pattern to the other four repositories
(def __init__(self, session_factory) + self._sf = session_factory)
- auth/config.py: drop users_db_path field — storage is configured
through config.database like every other table
- deps.py/get_local_provider: construct SQLiteUserRepository with
the shared session factory, fail fast if engine is not initialised
- tests/test_auth.py: rewrite test_sqlite_round_trip_new_fields to
use the shared engine (init_engine + close_engine in a tempdir)
- tests/test_auth_type_system.py: add per-test autouse fixture that
spins up a scratch engine and resets deps._cached_* singletons
* refactor(auth): remove SQL orphan migration (unused in supported scenarios)
The _migrate_orphan_sql_tables helper existed to bind NULL owner_id
rows in threads_meta, runs, run_events, and feedback to the admin on
first boot. But in every supported upgrade path, it's a no-op:
1. Fresh install: create_all builds fresh tables, no legacy rows
2. No-auth → with-auth (no existing persistence DB): persistence
tables are created fresh by create_all, no legacy rows
3. No-auth → with-auth (has existing persistence DB from #1930):
NOT a supported upgrade path — "有 DB 到有 DB" schema evolution
is out of scope; users wipe DB or run manual ALTER
So the SQL orphan migration never has anything to do in the
supported matrix. Delete the function, simplify _ensure_admin_user
from a 3-step pipeline to a 2-step one (admin creation + LangGraph
store orphan migration only).
LangGraph store orphan migration stays: it serves the real
"no-auth → with-auth" upgrade path where a user's existing LangGraph
thread metadata has no owner_id field and needs to be stamped with
the newly-created admin's id.
Tests: 284 passed (auth + persistence + isolation)
Lint: clean
* security(auth): write initial admin password to 0600 file instead of logs
CodeQL py/clear-text-logging-sensitive-data flagged 3 call sites that
logged the auto-generated admin password to stdout via logger.info().
Production log aggregators (ELK/Splunk/etc) would have captured those
cleartext secrets. Replace with a shared helper that writes to
.deer-flow/admin_initial_credentials.txt with mode 0600, and log only
the path.
New file
--------
- app/gateway/auth/credential_file.py: write_initial_credentials()
helper. Takes email, password, and a "initial"/"reset" label.
Creates .deer-flow/ if missing, writes a header comment plus the
email+password, chmods 0o600, returns the absolute Path.
Modified
--------
- app/gateway/app.py: both _ensure_admin_user paths (fresh creation
+ needs_setup password reset) now write to file and log the path
- app/gateway/auth/reset_admin.py: rewritten to use the shared ORM
repo (SQLiteUserRepository with session_factory) and the
credential_file helper. The previous implementation was broken
after the earlier ORM refactor — it still imported _get_users_conn
and constructed SQLiteUserRepository() without a session factory.
No tests changed — the three password-log sites are all exercised
via existing test_ensure_admin.py which checks that startup
succeeds, not that a specific string appears in logs.
CodeQL alerts 272, 283, 284: all resolved.
* security(auth): strict JWT validation in middleware (fix junk cookie bypass)
AUTH_TEST_PLAN test 7.5.8 expects junk cookies to be rejected with
401. The previous middleware behaviour was "presence-only": check
that some access_token cookie exists, then pass through. In
combination with my Task-12 decision to skip @require_auth
decorators on routes, this created a gap where a request with any
cookie-shaped string (e.g. access_token=not-a-jwt) would bypass
authentication on routes that do not touch the repository
(/api/models, /api/mcp/config, /api/memory, /api/skills, …).
Fix: middleware now calls get_current_user_from_request() strictly
and catches the resulting HTTPException to render a 401 with the
proper fine-grained error code (token_invalid, token_expired,
user_not_found, …). On success it stamps request.state.user and
the contextvar so repository-layer owner filters work downstream.
The 4 old "_with_cookie_passes" tests in test_auth_middleware.py
were written for the presence-only behaviour; they asserted that
a junk cookie would make the handler return 200. They are renamed
to "_with_junk_cookie_rejected" and their assertions flipped to
401. The negative path (no cookie → 401 not_authenticated)
is unchanged.
Verified:
no cookie → 401 not_authenticated
junk cookie → 401 token_invalid (the fixed bug)
expired cookie → 401 token_expired
Tests: 284 passed (auth + persistence + isolation)
Lint: clean
* security(auth): wire @require_permission(owner_check=True) on isolation routes
Apply the require_permission decorator to all 28 routes that take a
{thread_id} path parameter. Combined with the strict middleware
(previous commit), this gives the double-layer protection that
AUTH_TEST_PLAN test 7.5.9 documents:
Layer 1 (AuthMiddleware): cookie + JWT validation, rejects junk
cookies and stamps request.state.user
Layer 2 (@require_permission with owner_check=True): per-resource
ownership verification via
ThreadMetaStore.check_access — returns
404 if a different user owns the thread
The decorator's owner_check branch is rewritten to use the SQL
thread_meta_repo (the 2.0-rc persistence layer) instead of the
LangGraph store path that PR #1728 used (_store_get / get_store
in routers/threads.py). The inject_record convenience is dropped
— no caller in 2.0 needs the LangGraph blob, and the SQL repo has
a different shape.
Routes decorated (28 total):
- threads.py: delete, patch, get, get-state, post-state, post-history
- thread_runs.py: post-runs, post-runs-stream, post-runs-wait,
list_runs, get_run, cancel_run, join_run, stream_existing_run,
list_thread_messages, list_run_messages, list_run_events,
thread_token_usage
- feedback.py: create, list, stats, delete
- uploads.py: upload (added Request param), list, delete
- artifacts.py: get_artifact
- suggestions.py: generate (renamed body parameter to avoid
conflict with FastAPI Request)
Test fixes:
- test_suggestions_router.py: bypass the decorator via __wrapped__
(the unit tests cover parsing logic, not auth — no point spinning
up a thread_meta_repo just to test JSON unwrapping)
- test_auth_middleware.py 4 fake-cookie tests: already updated in
the previous commit (745bf432)
Tests: 293 passed (auth + persistence + isolation + suggestions)
Lint: clean
* security(auth): defense-in-depth fixes from release validation pass
Eight findings caught while running the AUTH_TEST_PLAN end-to-end against
the deployed sg_dev stack. Each is a pre-condition for shipping
release/2.0-rc that the previous PRs missed.
Backend hardening
- routers/auth.py: rate limiter X-Real-IP now requires AUTH_TRUSTED_PROXIES
whitelist (CIDR/IP allowlist). Without nginx in front, the previous code
honored arbitrary X-Real-IP, letting an attacker rotate the header to
fully bypass the per-IP login lockout.
- routers/auth.py: 36-entry common-password blocklist via Pydantic
field_validator on RegisterRequest + ChangePasswordRequest. The shared
_validate_strong_password helper keeps the constraint in one place.
- routers/threads.py: ThreadCreateRequest + ThreadPatchRequest strip
server-reserved metadata keys (owner_id, user_id) via Pydantic
field_validator so a forged value can never round-trip back to other
clients reading the same thread. The actual ownership invariant stays
on the threads_meta row; this closes the metadata-blob echo gap.
- authz.py + thread_meta/sql.py: require_permission gains a require_existing
flag plumbed through check_access(require_existing=True). Destructive
routes (DELETE/PATCH/state-update/runs/feedback) now treat a missing
thread_meta row as 404 instead of "untracked legacy thread, allow",
closing the cross-user delete-idempotence gap where any user could
successfully DELETE another user's deleted thread.
- repositories/sqlite.py + base.py: update_user raises UserNotFoundError
on a vanished row instead of silently returning the input. Concurrent
delete during password reset can no longer look like a successful update.
- runtime/user_context.py: resolve_owner_id() coerces User.id (UUID) to
str at the contextvar boundary so SQLAlchemy String(64) columns can
bind it. The whole 2.0-rc isolation pipeline was previously broken
end-to-end (POST /api/threads → 500 "type 'UUID' is not supported").
- persistence/engine.py: SQLAlchemy listener enables PRAGMA journal_mode=WAL,
synchronous=NORMAL, foreign_keys=ON on every new SQLite connection.
TC-UPG-06 in the test plan expects WAL; previous code shipped with the
default 'delete' journal.
- auth_middleware.py: stamp request.state.auth = AuthContext(...) so
@require_permission's short-circuit fires; previously every isolation
request did a duplicate JWT decode + users SELECT. Also unifies the
401 payload through AuthErrorResponse(...).model_dump().
- app.py: _ensure_admin_user restructure removes the noqa F821 scoping
bug where 'password' was referenced outside the branch that defined it.
New _announce_credentials helper absorbs the duplicate log block in
the fresh-admin and reset-admin branches.
* fix(frontend+nginx): rollout CSRF on every state-changing client path
The frontend was 100% broken in gateway-pro mode for any user trying to
open a specific chat thread. Three cumulative bugs each silently
masked the next.
LangGraph SDK CSRF gap (api-client.ts)
- The Client constructor took only apiUrl, no defaultHeaders, no fetch
interceptor. The SDK's internal fetch never sent X-CSRF-Token, so
every state-changing /api/langgraph-compat/* call (runs/stream,
threads/search, threads/{tid}/history, ...) hit CSRFMiddleware and
got 403 before reaching the auth check. UI symptom: empty thread page
with no error message; the SPA's hooks swallowed the rejection.
- Fix: pass an onRequest hook that injects X-CSRF-Token from the
csrf_token cookie per request. Reading the cookie per call (not at
construction time) handles login / logout / password-change cookie
rotation transparently. The SDK's prepareFetchOptions calls
onRequest for both regular requests AND streaming/SSE/reconnect, so
the same hook covers runs.stream and runs.joinStream.
Raw fetch CSRF gap (7 files)
- Audit: 11 frontend fetch sites, only 2 included CSRF (login/setup +
account-settings change-password). The other 7 routed through raw
fetch() with no header — suggestions, memory, agents, mcp, skills,
uploads, and the local thread cleanup hook all 403'd silently.
- Fix: enhance fetcher.ts:fetchWithAuth to auto-inject X-CSRF-Token on
POST/PUT/DELETE/PATCH from a single shared readCsrfCookie() helper.
Convert all 7 raw fetch() callers to fetchWithAuth so the contract
is centrally enforced. api-client.ts and fetcher.ts share
readCsrfCookie + STATE_CHANGING_METHODS to avoid drift.
nginx routing + buffering (nginx.local.conf)
- The auth feature shipped without updating the nginx config: per-API
explicit location blocks but no /api/v1/auth/, /api/feedback, /api/runs.
The frontend's client-side fetches to /api/v1/auth/login/local 404'd
from the Next.js side because nginx routed /api/* to the frontend.
- Fix: add catch-all `location /api/` that proxies to the gateway.
nginx longest-prefix matching keeps the explicit blocks (/api/models,
/api/threads regex, /api/langgraph/, ...) winning for their paths.
- Fix: disable proxy_buffering + proxy_request_buffering for the
frontend `location /` block. Without it, nginx tries to spool large
Next.js chunks into /var/lib/nginx/proxy (root-owned) and fails with
Permission denied → ERR_INCOMPLETE_CHUNKED_ENCODING → ChunkLoadError.
* test(auth): release-validation test infra and new coverage
Test fixtures and unit tests added during the validation pass.
Router test helpers (NEW: tests/_router_auth_helpers.py)
- make_authed_test_app(): builds a FastAPI test app with a stub
middleware that stamps request.state.user + request.state.auth and a
permissive thread_meta_repo mock. TestClient-based router tests
(test_artifacts_router, test_threads_router) use it instead of bare
FastAPI() so the new @require_permission(owner_check=True) decorators
short-circuit cleanly.
- call_unwrapped(): walks the __wrapped__ chain to invoke the underlying
handler without going through the authz wrappers. Direct-call tests
(test_uploads_router) use it. Typed with ParamSpec so the wrapped
signature flows through.
Backend test additions
- test_auth.py: 7 tests for the new _get_client_ip trust model (no
proxy / trusted proxy / untrusted peer / XFF rejection / invalid
CIDR / no client). 5 tests for the password blocklist (literal,
case-insensitive, strong password accepted, change-password binding,
short-password length-check still fires before blocklist).
test_update_user_raises_when_row_concurrently_deleted: closes a
shipped-without-coverage gap on the new UserNotFoundError contract.
- test_thread_meta_repo.py: 4 tests for check_access(require_existing=True)
— strict missing-row denial, strict owner match, strict owner mismatch,
strict null-owner still allowed (shared rows survive the tightening).
- test_ensure_admin.py: 3 tests for _migrate_orphaned_threads /
_iter_store_items pagination, covering the TC-UPG-02 upgrade story
end-to-end via mock store. Closes the gap where the cursor pagination
was untested even though the previous PR rewrote it.
- test_threads_router.py: 5 tests for _strip_reserved_metadata
(owner_id removal, user_id removal, safe-keys passthrough, empty
input, both-stripped).
- test_auth_type_system.py: replace "password123" fixtures with
Tr0ub4dor3a / AnotherStr0ngPwd! so the new password blocklist
doesn't reject the test data.
* docs(auth): refresh TC-DOCKER-05 + document Docker validation gap
- AUTH_TEST_PLAN.md TC-DOCKER-05: the previous expectation
("admin password visible in docker logs") was stale after the simplify
pass that moved credentials to a 0600 file. The grep "Password:" check
would have silently failed and given a false sense of coverage. New
expectation matches the actual file-based path: 0600 file in
DEER_FLOW_HOME, log shows the path (not the secret), reverse-grep
asserts no leaked password in container logs.
- NEW: docs/AUTH_TEST_DOCKER_GAP.md documents the only un-executed
block in the test plan (TC-DOCKER-01..06). Reason: sg_dev validation
host has no Docker daemon installed. The doc maps each Docker case
to an already-validated bare-metal equivalent (TC-1.1, TC-REENT-01,
TC-API-02 etc.) so the gap is auditable, and includes pre-flight
reproduction steps for whoever has Docker available.
---------
Co-authored-by: greatmengqi <chenmengqi.0376@bytedance.com>
This commit is contained in:
@@ -54,7 +54,6 @@
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.33",
|
||||
"best-effort-json-parser": "^1.2.1",
|
||||
"better-auth": "^1.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+6
-151
@@ -113,9 +113,12 @@ importers:
|
||||
best-effort-json-parser:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
<<<<<<< HEAD
|
||||
better-auth:
|
||||
specifier: ^1.3
|
||||
version: 1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3))
|
||||
=======
|
||||
>>>>>>> e75a2ff2 (feat(auth): release-validation pass for 2.0-rc — 12 blockers + simplify follow-ups (#2008))
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -323,27 +326,6 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@better-auth/core@1.4.18':
|
||||
resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==}
|
||||
peerDependencies:
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
better-call: 1.1.8
|
||||
jose: ^6.1.0
|
||||
kysely: ^0.28.5
|
||||
nanostores: ^1.0.1
|
||||
|
||||
'@better-auth/telemetry@1.4.18':
|
||||
resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': 1.4.18
|
||||
|
||||
'@better-auth/utils@0.3.0':
|
||||
resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==}
|
||||
|
||||
'@better-fetch/fetch@1.1.21':
|
||||
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
|
||||
|
||||
'@braintree/sanitize-url@7.1.2':
|
||||
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
|
||||
|
||||
@@ -1096,14 +1078,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/ciphers@2.1.1':
|
||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2691,76 +2665,6 @@ packages:
|
||||
best-effort-json-parser@1.2.1:
|
||||
resolution: {integrity: sha512-UICSLibQdzS1f+PBsi3u2YE3SsdXcWicHUg3IMvfuaePS2AYnZJdJeKhGv5OM8/mqJwPt79aDrEJ1oa84tELvw==}
|
||||
|
||||
better-auth@1.4.18:
|
||||
resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==}
|
||||
peerDependencies:
|
||||
'@lynx-js/react': '*'
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
'@sveltejs/kit': ^2.0.0
|
||||
'@tanstack/react-start': ^1.0.0
|
||||
'@tanstack/solid-start': ^1.0.0
|
||||
better-sqlite3: ^12.0.0
|
||||
drizzle-kit: '>=0.31.4'
|
||||
drizzle-orm: '>=0.41.0'
|
||||
mongodb: ^6.0.0 || ^7.0.0
|
||||
mysql2: ^3.0.0
|
||||
next: ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
pg: ^8.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
solid-js: ^1.0.0
|
||||
svelte: ^4.0.0 || ^5.0.0
|
||||
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
'@lynx-js/react':
|
||||
optional: true
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
'@tanstack/react-start':
|
||||
optional: true
|
||||
'@tanstack/solid-start':
|
||||
optional: true
|
||||
better-sqlite3:
|
||||
optional: true
|
||||
drizzle-kit:
|
||||
optional: true
|
||||
drizzle-orm:
|
||||
optional: true
|
||||
mongodb:
|
||||
optional: true
|
||||
mysql2:
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
pg:
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vitest:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
better-call@1.1.8:
|
||||
resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
better-react-mathjax@2.3.0:
|
||||
resolution: {integrity: sha512-K0ceQC+jQmB+NLDogO5HCpqmYf18AU2FxDbLdduYgkHYWZApFggkHE4dIaXCV1NqeoscESYXXo1GSkY6fA295w==}
|
||||
peerDependencies:
|
||||
@@ -3993,9 +3897,6 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||
|
||||
@@ -4046,10 +3947,6 @@ packages:
|
||||
knitwork@1.3.0:
|
||||
resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==}
|
||||
|
||||
kysely@0.28.11:
|
||||
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
langium@3.3.1:
|
||||
resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -4474,10 +4371,6 @@ packages:
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
nanostores@1.1.0:
|
||||
resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -5071,9 +4964,6 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rou3@0.7.12:
|
||||
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
|
||||
|
||||
roughjs@4.6.6:
|
||||
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
|
||||
|
||||
@@ -5126,9 +5016,6 @@ packages:
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5893,27 +5780,6 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@standard-schema/spec': 1.1.0
|
||||
better-call: 1.1.8(zod@4.3.6)
|
||||
jose: 6.1.3
|
||||
kysely: 0.28.11
|
||||
nanostores: 1.1.0
|
||||
zod: 4.3.6
|
||||
|
||||
'@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
|
||||
'@better-auth/utils@0.3.0': {}
|
||||
|
||||
'@better-fetch/fetch@1.1.21': {}
|
||||
|
||||
'@braintree/sanitize-url@7.1.2': {}
|
||||
|
||||
'@cfworker/json-schema@4.1.1': {}
|
||||
@@ -6762,10 +6628,6 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@16.1.7':
|
||||
optional: true
|
||||
|
||||
'@noble/ciphers@2.1.1': {}
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -8387,6 +8249,7 @@ snapshots:
|
||||
|
||||
best-effort-json-parser@1.2.1: {}
|
||||
|
||||
<<<<<<< HEAD
|
||||
better-auth@1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
@@ -8417,6 +8280,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
=======
|
||||
>>>>>>> e75a2ff2 (feat(auth): release-validation pass for 2.0-rc — 12 blockers + simplify follow-ups (#2008))
|
||||
better-react-mathjax@2.3.0(react@19.2.4):
|
||||
dependencies:
|
||||
mathjax-full: 3.2.2
|
||||
@@ -9943,8 +9808,6 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -9990,8 +9853,6 @@ snapshots:
|
||||
|
||||
knitwork@1.3.0: {}
|
||||
|
||||
kysely@0.28.11: {}
|
||||
|
||||
langium@3.3.1:
|
||||
dependencies:
|
||||
chevrotain: 11.0.3
|
||||
@@ -10686,8 +10547,6 @@ snapshots:
|
||||
|
||||
nanoid@5.1.6: {}
|
||||
|
||||
nanostores@1.1.0: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -11469,8 +11328,6 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
rou3@0.7.12: {}
|
||||
|
||||
roughjs@4.6.6:
|
||||
dependencies:
|
||||
hachure-fill: 0.5.2
|
||||
@@ -11537,8 +11394,6 @@ snapshots:
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import { getServerSideUser } from "@/core/auth/server";
|
||||
import { assertNever } from "@/core/auth/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const result = await getServerSideUser();
|
||||
|
||||
switch (result.tag) {
|
||||
case "authenticated":
|
||||
redirect("/workspace");
|
||||
case "needs_setup":
|
||||
// Allow access to setup page
|
||||
return <AuthProvider initialUser={result.user}>{children}</AuthProvider>;
|
||||
case "unauthenticated":
|
||||
return <AuthProvider initialUser={null}>{children}</AuthProvider>;
|
||||
case "gateway_unavailable":
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<p className="text-muted-foreground">
|
||||
Service temporarily unavailable.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm"
|
||||
>
|
||||
Retry
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
case "config_error":
|
||||
throw new Error(result.message);
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { parseAuthError } from "@/core/auth/types";
|
||||
|
||||
/**
|
||||
* Validate next parameter
|
||||
* Prevent open redirect attacks
|
||||
* Per RFC-001: Only allow relative paths starting with /
|
||||
*/
|
||||
function validateNextParam(next: string | null): string | null {
|
||||
if (!next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need start with / (relative path)
|
||||
if (!next.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Disallow protocol-relative URLs
|
||||
if (
|
||||
next.startsWith("//") ||
|
||||
next.startsWith("http://") ||
|
||||
next.startsWith("https://")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Disallow URLs with different protocols (e.g., javascript:, data:, etc)
|
||||
if (next.includes(":") && !next.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Valid relative path
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get next parameter for validated redirect
|
||||
const nextParam = searchParams.get("next");
|
||||
const redirectPath = validateNextParam(nextParam) ?? "/workspace";
|
||||
|
||||
// Redirect if already authenticated (client-side, post-login)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(redirectPath);
|
||||
}
|
||||
}, [isAuthenticated, redirectPath, router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const endpoint = isLogin
|
||||
? "/api/v1/auth/login/local"
|
||||
: "/api/v1/auth/register";
|
||||
const body = isLogin
|
||||
? `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`
|
||||
: JSON.stringify({ email, password });
|
||||
|
||||
const headers: HeadersInit = isLogin
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: { "Content-Type": "application/json" };
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
credentials: "include", // Important: include HttpOnly cookie
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const authError = parseAuthError(data);
|
||||
setError(authError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Both login and register set a cookie — redirect to workspace
|
||||
router.push(redirectPath);
|
||||
} catch (_err) {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a]">
|
||||
<div className="border-border/20 w-full max-w-md space-y-6 rounded-lg border bg-black/50 p-8 backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<h1 className="font-serif text-3xl">DeerFlow</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{isLogin ? "Sign in to your account" : "Create a new account"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="mt-1 bg-white text-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="•••••••"
|
||||
required
|
||||
minLength={isLogin ? 6 : 8}
|
||||
className="mt-1 bg-white text-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading
|
||||
? "Please wait..."
|
||||
: isLogin
|
||||
? "Sign In"
|
||||
: "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsLogin(!isLogin);
|
||||
setError("");
|
||||
}}
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{isLogin
|
||||
? "Don't have an account? Sign up"
|
||||
: "Already have an account? Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
<Link href="/" className="hover:underline">
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { parseAuthError } from "@/core/auth/types";
|
||||
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
new_email: email || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const authError = parseAuthError(data);
|
||||
setError(authError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/workspace");
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-sm space-y-6 p-6">
|
||||
<div className="text-center">
|
||||
<h1 className="font-serif text-3xl">DeerFlow</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Complete admin account setup
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Set your real email and a new password.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSetup} className="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Current password (from console log)"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Setting up..." : "Complete Setup"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
import { auth } from "@/server/better-auth";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||
@@ -1,35 +1,58 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { Toaster } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import { getServerSideUser } from "@/core/auth/server";
|
||||
import { assertNever } from "@/core/auth/types";
|
||||
|
||||
function parseSidebarOpenCookie(
|
||||
value: string | undefined,
|
||||
): boolean | undefined {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
import { WorkspaceContent } from "./workspace-content";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function WorkspaceLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const cookieStore = await cookies();
|
||||
const initialSidebarOpen = parseSidebarOpenCookie(
|
||||
cookieStore.get("sidebar_state")?.value,
|
||||
);
|
||||
const result = await getServerSideUser();
|
||||
|
||||
return (
|
||||
<QueryClientProvider>
|
||||
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
switch (result.tag) {
|
||||
case "authenticated":
|
||||
return (
|
||||
<AuthProvider initialUser={result.user}>
|
||||
<WorkspaceContent>{children}</WorkspaceContent>
|
||||
</AuthProvider>
|
||||
);
|
||||
case "needs_setup":
|
||||
redirect("/setup");
|
||||
case "unauthenticated":
|
||||
redirect("/login");
|
||||
case "gateway_unavailable":
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<p className="text-muted-foreground">
|
||||
Service temporarily unavailable.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
The backend may be restarting. Please wait a moment and try again.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/workspace"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm"
|
||||
>
|
||||
Retry
|
||||
</Link>
|
||||
<Link
|
||||
href="/api/v1/auth/logout"
|
||||
className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm"
|
||||
>
|
||||
Logout & Reset
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "config_error":
|
||||
throw new Error(result.message);
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
|
||||
function parseSidebarOpenCookie(
|
||||
value: string | undefined,
|
||||
): boolean | undefined {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function WorkspaceContent({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const cookieStore = await cookies();
|
||||
const initialSidebarOpen = parseSidebarOpenCookie(
|
||||
cookieStore.get("sidebar_state")?.value,
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider>
|
||||
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { fetchWithAuth } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
@@ -395,16 +396,19 @@ export function InputBox({
|
||||
setFollowupsLoading(true);
|
||||
setFollowups([]);
|
||||
|
||||
fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: recent,
|
||||
n: 3,
|
||||
model_name: context.model_name ?? undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: recent,
|
||||
n: 3,
|
||||
model_name: context.model_name ?? undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return { suggestions: [] as string[] };
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { LogOutIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { fetchWithAuth, getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { parseAuthError } from "@/core/auth/types";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function AccountSettingsPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchWithAuth("/api/v1/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCsrfHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const authError = parseAuthError(data);
|
||||
setError(authError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage("Password changed successfully");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection title="Profile">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">Email</span>
|
||||
<span className="text-sm font-medium">{user?.email ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">Role</span>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{user?.system_role ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Change Password">
|
||||
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Current password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{message && <p className="text-sm text-green-500">{message}</p>}
|
||||
<Button type="submit" variant="outline" size="sm" disabled={loading}>
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Session">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOutIcon className="size-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
UserIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
||||
@@ -27,6 +29,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
@@ -54,6 +57,11 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "account",
|
||||
label: t.settings.sections.account,
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
label: t.settings.sections.appearance,
|
||||
@@ -74,6 +82,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{ id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||
],
|
||||
[
|
||||
t.settings.sections.account,
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
@@ -122,8 +131,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||
<div className="min-w-0 space-y-8 p-6">
|
||||
<ScrollArea className="h-full min-h-0 rounded-lg border">
|
||||
<div className="space-y-8 p-6">
|
||||
{activeSection === "account" && <AccountSettingsPage />}
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithAuth } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
@@ -28,7 +29,7 @@ export async function getAgent(name: string): Promise<Agent> {
|
||||
}
|
||||
|
||||
export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents`, {
|
||||
const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
@@ -44,7 +45,7 @@ export async function updateAgent(
|
||||
name: string,
|
||||
request: UpdateAgentRequest,
|
||||
): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
@@ -57,7 +58,7 @@ export async function updateAgent(
|
||||
}
|
||||
|
||||
export async function deleteAgent(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
|
||||
@@ -4,11 +4,37 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { isStateChangingMethod, readCsrfCookie } from "./fetcher";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
/**
|
||||
* SDK ``onRequest`` hook that mints the ``X-CSRF-Token`` header from the
|
||||
* live ``csrf_token`` cookie just before each outbound fetch.
|
||||
*
|
||||
* Reading the cookie per-request (rather than baking it into the SDK's
|
||||
* ``defaultHeaders`` at construction) handles login / logout / password
|
||||
* change cookie rotation transparently. Both the ``/langgraph-compat/*``
|
||||
* SDK path and the direct REST endpoints in ``fetcher.ts:fetchWithAuth``
|
||||
* share :func:`readCsrfCookie` and :const:`STATE_CHANGING_METHODS` so
|
||||
* the contract stays in lockstep.
|
||||
*/
|
||||
function injectCsrfHeader(_url: URL, init: RequestInit): RequestInit {
|
||||
if (!isStateChangingMethod(init.method ?? "GET")) {
|
||||
return init;
|
||||
}
|
||||
const token = readCsrfCookie();
|
||||
if (!token) return init;
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("X-CSRF-Token")) {
|
||||
headers.set("X-CSRF-Token", token);
|
||||
}
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
function createCompatibleClient(isMock?: boolean): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest: injectCsrfHeader,
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { buildLoginUrl } from "@/core/auth/types";
|
||||
|
||||
/** HTTP methods that the gateway's CSRFMiddleware checks. */
|
||||
export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
|
||||
export const STATE_CHANGING_METHODS: ReadonlySet<StateChangingMethod> = new Set(
|
||||
["POST", "PUT", "DELETE", "PATCH"],
|
||||
);
|
||||
|
||||
/** Mirror of the gateway's ``should_check_csrf`` decision. */
|
||||
export function isStateChangingMethod(method: string): boolean {
|
||||
return (STATE_CHANGING_METHODS as ReadonlySet<string>).has(
|
||||
method.toUpperCase(),
|
||||
);
|
||||
}
|
||||
|
||||
const CSRF_COOKIE_PREFIX = "csrf_token=";
|
||||
|
||||
/**
|
||||
* Read the ``csrf_token`` cookie set by the gateway at login.
|
||||
*
|
||||
* SSR-safe: returns ``null`` when ``document`` is undefined so the same
|
||||
* helper can be imported from server components without a guard.
|
||||
*
|
||||
* Uses `String.split` instead of a regex to side-step ESLint's
|
||||
* `prefer-regexp-exec` rule and the cookie value's reliable `; `
|
||||
* separator (set by the gateway, not the browser, so format is stable).
|
||||
*/
|
||||
export function readCsrfCookie(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
for (const pair of document.cookie.split("; ")) {
|
||||
if (pair.startsWith(CSRF_COOKIE_PREFIX)) {
|
||||
return decodeURIComponent(pair.slice(CSRF_COOKIE_PREFIX.length));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with credentials and automatic CSRF protection.
|
||||
*
|
||||
* Two centralized contracts every API call needs:
|
||||
*
|
||||
* 1. ``credentials: "include"`` so the HttpOnly access_token cookie
|
||||
* accompanies cross-origin SSR-routed requests.
|
||||
* 2. ``X-CSRF-Token`` header on state-changing methods (POST/PUT/
|
||||
* DELETE/PATCH), echoed from the ``csrf_token`` cookie. The gateway's
|
||||
* CSRFMiddleware enforces Double Submit Cookie comparison and returns
|
||||
* 403 if the header is missing — silently breaking every call site
|
||||
* that uses raw ``fetch()`` instead of this wrapper.
|
||||
*
|
||||
* Auto-redirects to ``/login`` on 401. Caller-supplied headers are
|
||||
* preserved; the helper only ADDS the CSRF header when it isn't already
|
||||
* present, so explicit overrides win.
|
||||
*/
|
||||
export async function fetchWithAuth(
|
||||
input: RequestInfo | string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
|
||||
// Inject CSRF for state-changing methods. GET/HEAD/OPTIONS/TRACE skip
|
||||
// it to mirror the gateway's ``should_check_csrf`` logic exactly.
|
||||
let headers = init?.headers;
|
||||
if (isStateChangingMethod(init?.method ?? "GET")) {
|
||||
const token = readCsrfCookie();
|
||||
if (token) {
|
||||
// Fresh Headers instance so we don't mutate caller-supplied objects.
|
||||
const merged = new Headers(headers);
|
||||
if (!merged.has("X-CSRF-Token")) {
|
||||
merged.set("X-CSRF-Token", token);
|
||||
}
|
||||
headers = merged;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
window.location.href = buildLoginUrl(window.location.pathname);
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for CSRF-protected requests.
|
||||
*
|
||||
* **Prefer :func:`fetchWithAuth`** for new code — it injects the header
|
||||
* automatically on state-changing methods. This helper exists for legacy
|
||||
* call sites that need to compose headers manually (e.g. inside
|
||||
* `next/server` route handlers that build their own ``Headers`` object).
|
||||
*
|
||||
* Per RFC-001: Double Submit Cookie pattern.
|
||||
*/
|
||||
export function getCsrfHeaders(): HeadersInit {
|
||||
const token = readCsrfCookie();
|
||||
return token ? { "X-CSRF-Token": token } : {};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { type User, buildLoginUrl } from "./types";
|
||||
|
||||
// Re-export for consumers
|
||||
export type { User };
|
||||
|
||||
/**
|
||||
* Authentication context provided to consuming components
|
||||
*/
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
initialUser: User | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthProvider - Unified authentication context for the application
|
||||
*
|
||||
* Per RFC-001:
|
||||
* - Only holds display information (user), never JWT or tokens
|
||||
* - initialUser comes from server-side guard, avoiding client flicker
|
||||
* - Provides logout and refresh capabilities
|
||||
*/
|
||||
export function AuthProvider({ children, initialUser }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(initialUser);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isAuthenticated = user !== null;
|
||||
|
||||
/**
|
||||
* Fetch current user from FastAPI
|
||||
* Used when initialUser might be stale (e.g., after tab was inactive)
|
||||
*/
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch("/api/v1/auth/me", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data);
|
||||
} else if (res.status === 401) {
|
||||
// Session expired or invalid
|
||||
setUser(null);
|
||||
// Redirect to login if on a protected route
|
||||
if (pathname?.startsWith("/workspace")) {
|
||||
router.push(buildLoginUrl(pathname));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh user:", err);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
/**
|
||||
* Logout - call FastAPI logout endpoint and clear local state
|
||||
* Per RFC-001: Immediately clear local state, don't wait for server confirmation
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
// Immediately clear local state to prevent UI flicker
|
||||
setUser(null);
|
||||
|
||||
try {
|
||||
await fetch("/api/v1/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Logout request failed:", err);
|
||||
// Still redirect even if logout request fails
|
||||
}
|
||||
|
||||
// Redirect to home page
|
||||
router.push("/");
|
||||
}, [router]);
|
||||
|
||||
/**
|
||||
* Handle visibility change - refresh user when tab becomes visible again.
|
||||
* Throttled to at most once per 60 s to avoid spamming the backend on rapid tab switches.
|
||||
*/
|
||||
const lastCheckRef = React.useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible" || user === null) return;
|
||||
const now = Date.now();
|
||||
if (now - lastCheckRef.current < 60_000) return;
|
||||
lastCheckRef.current = now;
|
||||
void refreshUser();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [user, refreshUser]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access authentication context
|
||||
* Throws if used outside AuthProvider - this is intentional for proper usage
|
||||
*/
|
||||
export function useAuth(): AuthContextType {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to require authentication - redirects to login if not authenticated
|
||||
* Useful for client-side checks in addition to server-side guards
|
||||
*/
|
||||
export function useRequireAuth(): AuthContextType {
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
// Only redirect if we're sure user is not authenticated (not just loading)
|
||||
if (!auth.isLoading && !auth.isAuthenticated) {
|
||||
router.push(buildLoginUrl(pathname || "/workspace"));
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.isLoading, router, pathname]);
|
||||
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const gatewayConfigSchema = z.object({
|
||||
internalGatewayUrl: z.string().url(),
|
||||
trustedOrigins: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export type GatewayConfig = z.infer<typeof gatewayConfigSchema>;
|
||||
|
||||
let _cached: GatewayConfig | null = null;
|
||||
|
||||
export function getGatewayConfig(): GatewayConfig {
|
||||
if (_cached) return _cached;
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const rawUrl = process.env.DEER_FLOW_INTERNAL_GATEWAY_BASE_URL?.trim();
|
||||
const internalGatewayUrl =
|
||||
rawUrl?.replace(/\/+$/, "") ??
|
||||
(isDev ? "http://localhost:8001" : undefined);
|
||||
|
||||
const rawOrigins = process.env.DEER_FLOW_TRUSTED_ORIGINS?.trim();
|
||||
const trustedOrigins = rawOrigins
|
||||
? rawOrigins
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: isDev
|
||||
? ["http://localhost:3000"]
|
||||
: undefined;
|
||||
|
||||
_cached = gatewayConfigSchema.parse({ internalGatewayUrl, trustedOrigins });
|
||||
return _cached;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface ProxyPolicy {
|
||||
/** Allowed upstream path prefixes */
|
||||
readonly allowedPaths: readonly string[];
|
||||
/** Request headers to strip before forwarding */
|
||||
readonly strippedRequestHeaders: ReadonlySet<string>;
|
||||
/** Response headers to strip before returning */
|
||||
readonly strippedResponseHeaders: ReadonlySet<string>;
|
||||
/** Credential mode: which cookie to forward */
|
||||
readonly credential: { readonly type: "cookie"; readonly name: string };
|
||||
/** Timeout in ms */
|
||||
readonly timeoutMs: number;
|
||||
/** CSRF: required for non-GET/HEAD */
|
||||
readonly csrf: boolean;
|
||||
}
|
||||
|
||||
export const LANGGRAPH_COMPAT_POLICY: ProxyPolicy = {
|
||||
allowedPaths: [
|
||||
"threads",
|
||||
"runs",
|
||||
"assistants",
|
||||
"store",
|
||||
"models",
|
||||
"mcp",
|
||||
"skills",
|
||||
"memory",
|
||||
],
|
||||
strippedRequestHeaders: new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"origin",
|
||||
"referer",
|
||||
"proxy-authorization",
|
||||
"proxy-authenticate",
|
||||
]),
|
||||
strippedResponseHeaders: new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
]),
|
||||
credential: { type: "cookie", name: "access_token" },
|
||||
timeoutMs: 120_000,
|
||||
csrf: true,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { getGatewayConfig } from "./gateway-config";
|
||||
import { type AuthResult, userSchema } from "./types";
|
||||
|
||||
const SSR_AUTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Fetch the authenticated user from the gateway using the request's cookies.
|
||||
* Returns a tagged AuthResult — callers use exhaustive switch, no try/catch.
|
||||
*/
|
||||
export async function getServerSideUser(): Promise<AuthResult> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get("access_token");
|
||||
|
||||
let internalGatewayUrl: string;
|
||||
try {
|
||||
internalGatewayUrl = getGatewayConfig().internalGatewayUrl;
|
||||
} catch (err) {
|
||||
return { tag: "config_error", message: String(err) };
|
||||
}
|
||||
|
||||
if (!sessionCookie) return { tag: "unauthenticated" };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, {
|
||||
headers: { Cookie: `access_token=${sessionCookie.value}` },
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout); // Clear immediately — covers all response branches
|
||||
|
||||
if (res.ok) {
|
||||
const parsed = userSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
console.error("[SSR auth] Malformed /auth/me response:", parsed.error);
|
||||
return { tag: "gateway_unavailable" };
|
||||
}
|
||||
if (parsed.data.needs_setup) {
|
||||
return { tag: "needs_setup", user: parsed.data };
|
||||
}
|
||||
return { tag: "authenticated", user: parsed.data };
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { tag: "unauthenticated" };
|
||||
}
|
||||
console.error(`[SSR auth] /api/v1/auth/me responded ${res.status}`);
|
||||
return { tag: "gateway_unavailable" };
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
console.error("[SSR auth] Failed to reach gateway:", err);
|
||||
return { tag: "gateway_unavailable" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ── User schema (single source of truth) ──────────────────────────
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
system_role: z.enum(["admin", "user"]),
|
||||
needs_setup: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
// ── SSR auth result (tagged union) ────────────────────────────────
|
||||
|
||||
export type AuthResult =
|
||||
| { tag: "authenticated"; user: User }
|
||||
| { tag: "needs_setup"; user: User }
|
||||
| { tag: "unauthenticated" }
|
||||
| { tag: "gateway_unavailable" }
|
||||
| { tag: "config_error"; message: string };
|
||||
|
||||
export function assertNever(x: never): never {
|
||||
throw new Error(`Unexpected auth result: ${JSON.stringify(x)}`);
|
||||
}
|
||||
|
||||
export function buildLoginUrl(returnPath: string): string {
|
||||
return `/login?next=${encodeURIComponent(returnPath)}`;
|
||||
}
|
||||
|
||||
// ── Backend error response parsing ────────────────────────────────
|
||||
|
||||
const AUTH_ERROR_CODES = [
|
||||
"invalid_credentials",
|
||||
"token_expired",
|
||||
"token_invalid",
|
||||
"user_not_found",
|
||||
"email_already_exists",
|
||||
"provider_not_found",
|
||||
"not_authenticated",
|
||||
] as const;
|
||||
|
||||
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[number];
|
||||
|
||||
export interface AuthErrorResponse {
|
||||
code: AuthErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const authErrorSchema = z.object({
|
||||
code: z.enum(AUTH_ERROR_CODES),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export function parseAuthError(data: unknown): AuthErrorResponse {
|
||||
// Try top-level {code, message} first
|
||||
const parsed = authErrorSchema.safeParse(data);
|
||||
if (parsed.success) return parsed.data;
|
||||
|
||||
// Unwrap FastAPI's {detail: {code, message}} envelope
|
||||
if (typeof data === "object" && data !== null && "detail" in data) {
|
||||
const detail = (data as Record<string, unknown>).detail;
|
||||
const nested = authErrorSchema.safeParse(detail);
|
||||
if (nested.success) return nested.data;
|
||||
// Legacy string-detail responses
|
||||
if (typeof detail === "string") {
|
||||
return { code: "invalid_credentials", message: detail };
|
||||
}
|
||||
}
|
||||
|
||||
return { code: "invalid_credentials", message: "Authentication failed" };
|
||||
}
|
||||
@@ -236,6 +236,7 @@ export const enUS: Translations = {
|
||||
reportIssue: "Report a issue",
|
||||
contactUs: "Contact us",
|
||||
about: "About DeerFlow",
|
||||
logout: "Log out",
|
||||
},
|
||||
|
||||
// Conversation
|
||||
@@ -324,6 +325,7 @@ export const enUS: Translations = {
|
||||
title: "Settings",
|
||||
description: "Adjust how DeerFlow looks and behaves for you.",
|
||||
sections: {
|
||||
account: "Account",
|
||||
appearance: "Appearance",
|
||||
memory: "Memory",
|
||||
tools: "Tools",
|
||||
|
||||
@@ -168,6 +168,7 @@ export interface Translations {
|
||||
reportIssue: string;
|
||||
contactUs: string;
|
||||
about: string;
|
||||
logout: string;
|
||||
};
|
||||
|
||||
// Conversation
|
||||
@@ -253,6 +254,7 @@ export interface Translations {
|
||||
title: string;
|
||||
description: string;
|
||||
sections: {
|
||||
account: string;
|
||||
appearance: string;
|
||||
memory: string;
|
||||
tools: string;
|
||||
|
||||
@@ -224,6 +224,7 @@ export const zhCN: Translations = {
|
||||
reportIssue: "报告问题",
|
||||
contactUs: "联系我们",
|
||||
about: "关于 DeerFlow",
|
||||
logout: "退出登录",
|
||||
},
|
||||
|
||||
// Conversation
|
||||
@@ -309,6 +310,7 @@ export const zhCN: Translations = {
|
||||
title: "设置",
|
||||
description: "根据你的偏好调整 DeerFlow 的界面和行为。",
|
||||
sections: {
|
||||
account: "账号",
|
||||
appearance: "外观",
|
||||
memory: "记忆",
|
||||
tools: "工具",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithAuth } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { MCPConfig } from "./types";
|
||||
@@ -8,12 +9,15 @@ export async function loadMCPConfig() {
|
||||
}
|
||||
|
||||
export async function updateMCPConfig(config: MCPConfig) {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/mcp/config`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithAuth } from "../api/fetcher";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
|
||||
import type {
|
||||
@@ -85,14 +86,14 @@ export async function loadMemory(): Promise<UserMemory> {
|
||||
}
|
||||
|
||||
export async function clearMemory(): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory`, {
|
||||
const response = await fetchWithAuth(`${getBackendBaseURL()}/api/memory`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return readMemoryResponse(response, "Failed to clear memory");
|
||||
}
|
||||
|
||||
export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
|
||||
const response = await fetch(
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
@@ -107,26 +108,32 @@ export async function exportMemory(): Promise<UserMemory> {
|
||||
}
|
||||
|
||||
export async function importMemory(memory: UserMemory): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory/import`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/memory/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(memory),
|
||||
},
|
||||
body: JSON.stringify(memory),
|
||||
});
|
||||
);
|
||||
return readMemoryResponse(response, "Failed to import memory");
|
||||
}
|
||||
|
||||
export async function createMemoryFact(
|
||||
input: MemoryFactInput,
|
||||
): Promise<UserMemory> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/memory/facts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
);
|
||||
return readMemoryResponse(response, "Failed to create memory fact");
|
||||
}
|
||||
|
||||
@@ -134,7 +141,7 @@ export async function updateMemoryFact(
|
||||
factId: string,
|
||||
input: MemoryFactPatchInput,
|
||||
): Promise<UserMemory> {
|
||||
const response = await fetch(
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithAuth } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { Skill } from "./type";
|
||||
@@ -9,7 +10,7 @@ export async function loadSkills() {
|
||||
}
|
||||
|
||||
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
const response = await fetch(
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
||||
{
|
||||
method: "PUT",
|
||||
@@ -38,13 +39,16 @@ export interface InstallSkillResponse {
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/skills/install`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle HTTP error responses (4xx, 5xx)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
|
||||
import { getAPIClient } from "../api";
|
||||
import { fetchWithAuth } from "../api/fetcher";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
@@ -604,7 +605,7 @@ export function useDeleteThread() {
|
||||
mutationFn: async ({ threadId }: { threadId: string }) => {
|
||||
await apiClient.threads.delete(threadId);
|
||||
|
||||
const response = await fetch(
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* API functions for file uploads
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from "../api/fetcher";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
|
||||
export interface UploadedFileInfo {
|
||||
@@ -50,7 +51,7 @@ export async function uploadFiles(
|
||||
formData.append("files", file);
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -91,7 +92,7 @@ export async function deleteUploadedFile(
|
||||
threadId: string,
|
||||
filename: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch(
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
|
||||
@@ -7,12 +7,6 @@ export const env = createEnv({
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
BETTER_AUTH_SECRET:
|
||||
process.env.NODE_ENV === "production"
|
||||
? z.string()
|
||||
: z.string().optional(),
|
||||
BETTER_AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
BETTER_AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
GITHUB_OAUTH_TOKEN: z.string().optional(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
@@ -35,10 +29,6 @@ export const env = createEnv({
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
|
||||
BETTER_AUTH_GITHUB_CLIENT_ID: process.env.BETTER_AUTH_GITHUB_CLIENT_ID,
|
||||
BETTER_AUTH_GITHUB_CLIENT_SECRET:
|
||||
process.env.BETTER_AUTH_GITHUB_CLIENT_SECRET,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
|
||||
NEXT_PUBLIC_BACKEND_BASE_URL: process.env.NEXT_PUBLIC_BACKEND_BASE_URL,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
|
||||
export type Session = typeof authClient.$Infer.Session;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
@@ -1 +0,0 @@
|
||||
export { auth } from "./config";
|
||||
@@ -1,8 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
import { auth } from ".";
|
||||
|
||||
export const getSession = cache(async () =>
|
||||
auth.api.getSession({ headers: await headers() }),
|
||||
);
|
||||
Reference in New Issue
Block a user