From d2c76cb22149cf8b7c16dfdb03b67d536fa4fb89 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr <139972147+ZdenekSrotyr@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:24:28 +0200 Subject: [PATCH] User management + PAT + CLI distribution + HTML auth redirect (#9 #10 #11 #12) (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: redirect unauthenticated HTML routes to /login (#10) * docs(plan): user mgmt + PAT + CLI distribution implementation plan (#9 #10 #11 #12) * build(docker): produce wheel artifact for /cli/download (#9) * feat(db): schema v5 — users.active + deactivated_at/by (#11) * feat(api): /cli/download wheel + /cli/install.sh with baked server URL (#9) * feat(users): repository supports active flag + count_admins (#11) * feat(ui): /install page with per-deployment install instructions (#9) * feat(api): user PATCH/reset-password/set-password/activate/deactivate (#11) * fix(cli): da login prompts for password and sends it in body (#9) * test(api): safeguard tests for self-deactivate and last admin (#11) * feat(auth): reject requests from deactivated users (#11) * fixup(#10): propagate next through /login buttons + lock down sanitizer tests * feat(cli): da admin set-role/activate/deactivate/reset-password/set-password (#11) * feat(ui): /admin/users management page (#11) * feat(db): schema v6 — personal_access_tokens (#12) * feat(users): access_tokens repository (#12) * feat(auth): JWT carries typ (session|pat) and explicit jti (#12) * feat(auth): reject revoked/expired PATs; update last_used_at (#12) * feat(api): /auth/tokens CRUD + admin revoke; session-only guard (#12) * feat(cli): da auth token create/list/revoke (#12) * feat(ui): /profile page with PAT create/list/revoke (#12) * docs: PAT usage and session/PAT TTL clarification (#12) * feat(auth): PAT first-use-from-new-IP audit + last_used_ip (schema v7) (#12) Closes remaining acceptance gap from issue #12: audit_log entry on first use of a PAT from an IP that differs from the recorded last_used_ip. - schema v7: personal_access_tokens.last_used_ip column - AccessTokenRepository.mark_used now stores the client IP - get_current_user extracts client IP (X-Forwarded-For first hop, fallback to request.client.host) and emits a token.first_use_new_ip audit when the IP changes on a subsequent use (not the very first use) - tests: new-ip audit, same-ip no-op, first-ever-use no-op, schema v7 column * fix: address Devin review findings on PR #28 - app/main.py: exclude /auth/* from HTML redirect handler so JSON endpoints under /auth/ (PAT CRUD used by `da auth token` CLI) keep their 401 JSON contract (Devin #1, bug) - app/api/tokens.py: reject expires_in_days <= 0 explicitly; use `is not None` so 0 no longer silently creates a non-expiring token (Devin #2) - app/api/users.py: validate role against Role enum in create_user to match update_user and prevent 500 on role-protected requests later (Devin #3) - app/web/templates/admin_users.html: escape user-supplied strings before innerHTML; move onclick handlers to addEventListener via data attributes so emails with quotes / HTML no longer break the UI or enable stored XSS (Devin #4) - app/auth/router.py, app/auth/providers/{password,google}.py: reject deactivated users at login instead of issuing a JWT that would then fail on the next request — removes the confusing redirect loop (Devin #5) - CLAUDE.md: document schema v7 instead of stale v4 (Devin #6) - tests/test_web_ui.py: regression test for the /auth/* JSON 401 * feat(web): add /profile and /admin/users links to dashboard nav * feat(web): point setup banner at /install page * chore(web): drop unused setup_instructions context * fix: address Devin review round 2 on PR #28 - app/api/tokens.py: when expires_in_days is None (the "never" option), use a ~100-year JWT expiry so the token doesn't silently die in 24h via the session-default fallback in create_access_token. The real expiry enforcement stays in verify_token's DB-level check (Devin 🔴) - app/web/templates/profile.html: escape t.name and other user-supplied strings via esc() helper before innerHTML, same pattern as admin_users.html. Move revoke onclick to data-attribute + addEventListener (Devin 🟡) - app/api/cli_artifacts.py: use `mktemp -d` with X's at end of template for GNU/BSD portability, place wheel inside the temp dir and clean up with rm -rf (Devin 🚩) * feat(web): redesign /install page; make curl one-liner primary, collapse manual Rebuild the public /install page using the dashboard visual language (shared header, card layout, gradient hero, design tokens from style-custom.css). The page is now anchored on the one-liner install path: curl -fsSL /cli/install.sh | bash is rendered as the primary, prominent step 1, while the old manual wheel-download flow is tucked behind a closed-by-default
block for users in restricted/offline environments. Information architecture: hero (server URL + version) -> step 1: quick install (one-liner, big Copy button) -> step 2: create PAT on /profile + export DA_TOKEN / da auth whoami -> step 3: Claude Code / MCP via ~/.config/da/token.json -> collapsed "Manual install" details for download-wheel flow -> footer link to docs/HEADLESS_USAGE.md Every shell snippet has a vanilla-JS "Copy" button that confirms visually ("Copied!" for 1.5s) and falls back to textarea+execCommand on non-secure contexts. No new dependencies, no bundler. The route now also pulls an optional user so the header shows the same nav (Dashboard / Profile / Logout) as dashboard.html when a session exists, while staying fully public when signed out. * fix(cli): use real wheel filename in install.sh (broken pip/uv install) The installer wrote the downloaded wheel as agnes_cli.whl, which lacks a PEP-427 version component — both pip and uv tool install reject it and abort the one-liner. Use curl -OJ so Content-Disposition determines the on-disk filename, then resolve it via glob. Install an EXIT trap to remove the tmpdir even when install fails. * fix(web): correct manual install wheel glob and add PEP 668 / PATH hints - Wheel glob is agnes_the_ai_analyst-*.whl (not agnes-*.whl) — the old pattern never matched the real artefact name from the build. - Add — or — separator between uv tool install and pip install. - Warn that pip install --user is blocked on macOS Homebrew / modern Debian (PEP 668) and recommend uv tool install as the default path. - Both flows now show the ~/.local/bin PATH hint so a fresh shell can find the da binary after install. * fix(web): consistent session.user reference in install header The avatar-letter fallback inside {% if session.user %} was reading user.name / user.email directly, but the route dependency can pass user=None — those references resolved to an empty FlexDict and produced an empty avatar circle. Read everything through session.user to match the guard and the dashboard pattern. * fix(web): point headless usage link at GitHub source /docs/HEADLESS_USAGE.md 404s — no static route serves repo docs. Point the footer link at the rendered markdown on GitHub instead of adding a dedicated docs serving route just for one file. * feat(web): /install hero size, anon sign-in banner, step 2 copy polish - Bump hero h1 from 26px to 30px to match dashboard primary scale. - Anonymous visitors see a small sign-in banner above Step 2 (creating a token requires auth; without the banner the flow appears stuck). - Add an 'After generating your token' section label inside Step 2 so the /profile CTA button no longer looks wedged mid-sentence between adjacent paragraphs. * chore(web): /install a11y + version pill polish - aria-live='polite' on copy buttons so screen readers announce the 'Copied!' state change. - Replace redundant INSTANCE_NAME eyebrow (already in the header logo) with 'Getting started'. - Hide the version pill when AGNES_VERSION is unset/'dev' — avoids the misleading 'vdev' label in local/unbuilt runs. - Manual summary focus-visible outline-offset +2px (was -2px which clipped inside the card), and mark the chevron as decorative. * fix(web): use session.user in dashboard avatar fallback Inside {% if session.user %} guard, the avatar fallback referenced (user.name or user.email). If user is None the block crashes when the profile picture is absent. Align with the guard variable. * fix: address Devin review round 3 on PR #28 - app/api/users.py: stop auto-sending email from reset_password. The magic-link sender would deliver a "Login Link" that — when clicked — consumes the reset_token via verify_magic_link and logs the user in WITHOUT prompting for a new password. Admins now share the raw reset_token from the API response manually, or use set-password directly. email_sent is always False. Documented inline. (Devin 🟡) - app/api/cli_artifacts.py: harden /cli/install.sh generation against shell injection via Host header or AGNES_VERSION. base_url is validated against a strict scheme+host+port regex; version against an alnum + dot/dash/underscore allowlist. Both values are also piped through shlex.quote() as defense in depth. (Devin 🟡) The shared users.reset_token column between magic-link and password- reset flows (Devin 🚩) remains an architectural gap; splitting into separate columns needs schema v8 and is tracked for a follow-up PR. * docs, chore(grpn): manual-deploy helpers + hackathon deploy learnings Adds scripts/grpn/ — Makefile + agnes-auto-upgrade.sh + README for operating Agnes on GRPN's existing foundryai-development VM when the full Terraform flow is blocked by org policies: - iam.disableServiceAccountKeyCreation (org constraint) forbids SA JSON keys, so GCP_SA_KEY-based CI is unavailable - No projectIamAdmin delegation → bootstrap-gcp.sh can't grant roles - Secret Manager IAM bindings require setIamPolicy which editor lacks Helper targets: deploy, deploy-tag, recreate, restart, stop, start, status, version, logs, ps, env, ssh, tunnel, open, bootstrap-admin, set-data-source, install-cron, uninstall-cron. docs/superpowers/plans/2026-04-22-grpn-deploy-learnings.md — running log of all org-policy constraints hit during the hackathon deploy, with workarounds and derived follow-ups (WIF support, external_ip variable, customer onboarding IAM checklist). Not a replacement for the TF flow — stopgap until WIF lands. * fix(web): make header logos clickable links to home * feat(web): one-click "Setup a new Claude Code" button Adds a single-button flow on the dashboard and /install page that generates a fresh personal access token via POST /auth/tokens and copies a complete, paste-ready setup script (server URL, token, install/verify commands) to the clipboard. Falls back to a modal textarea when the clipboard is blocked; redirects to /login on 401; surfaces backend errors inline. - dashboard.html: replaces the top "Set up your local environment" anchor with a real button wired to setupNewClaude(). Removes the duplicate bottom setup banner to keep a single entry point. - install.html: for signed-in users, Step 1 leads with the one-click button and demotes the curl one-liner into a collapsible "Or run manually" aside. Anonymous visitors still see the curl flow plus a sign-in hint. - No new deps. Vanilla JS. Token lives in memory/clipboard only — never rendered into persistent DOM. * feat(cli): add "da auth import-token" for non-interactive PAT login Writes a provided JWT into ~/.config/da/token.json using the canonical {access_token, email, role} shape expected by save_token(). Decodes the token locally to pull email/role claims, verifies it against the server via GET /api/catalog/tables, and refuses to overwrite an existing token file if the server returns 401. --email / --role overrides exist for tokens missing those claims; --skip-verify bypasses the server round-trip for offline / CI scenarios. * test(cli): cover da auth import-token success + 401 + claim-fallback paths Three new tests in TestAuthImportToken: - valid JWT + 200 -> canonical token.json written - 401 from /api/catalog/tables -> exit 1, existing token file untouched - JWT without email/role claims -> refused without overrides, accepted with --email / --role flags * feat(web): update one-click Claude setup instructions — explicit uv install, import-token, skills question Replaces the fragile `cat > token.json < markup with the shared partial. Inline .header CSS left in place as harmless dead code (separate cleanup PR). - admin_users.html: rewritten with avatars, role pills (color-coded per role), toggle switch for active, search/filter input, toast notifications, modal dialogs replacing alert/confirm/prompt, one-click copy for the reset token, empty / loading states. All XSS-safe via the existing esc() helper + data-attribute event delegation. - tests/test_web_ui.py: smoke test that /admin/users renders the new shared header chrome and the modernized markup. * feat(api): serve CLI wheel at /cli/agnes.whl for direct uv install uv tool install inspects the URL path suffix to recognise a wheel, so /cli/download (which has no .whl suffix) cannot be installed directly. Expose a stable /cli/agnes.whl alias over the same wheel lookup so users can run: uv tool install --force https:///cli/agnes.whl * test(cli): cover da auth import-token --server persisting to config.yaml The server persistence was already implemented in the import-token command (save_config({server}) call) but not covered by tests. Add an explicit test so the one-step setup contract — single import-token call writes both token and server — cannot regress. * feat(web): simpler Claude setup — single uv install URL, single import-token call User feedback: the prior clipboard payload repeated the server URL and token across multiple steps (curl + tmpfile + install + rm + separate seed-config + import-token). Collapse to: 1. uv tool install --force {server_url}/cli/agnes.whl (single URL, direct) 2. da auth import-token --token ... --server ... (one call, persists both) 3. da auth whoami 4. skills (ask user first) 5. confirm uv accepts HTTPS URLs that end in .whl and installs them directly, so the tmpfile dance is unnecessary. import-token --server already persists the server to config.yaml, so no separate printf > config.yaml step. * fix(tests): update admin users heading assertion after template rename The admin_users.html template now uses

Users

instead of

User management

. Update the assertion to match. * feat(ui): unify header across remaining 7 standalone pages These 7 pages render their own full and don't extend base.html, so the previous unification commit only covered base + dashboard. Each had its own ad-hoc
markup with inconsistent classes (.top-header / .header / .page-header), inconsistent nav-link sets, and inconsistent avatar/email styling. Replace each inline
...
block with the shared {% include '_app_header.html' %} so /activity-center, /admin/permissions, /admin/tables, /catalog, /corporate-memory, /corporate-memory/admin, and /install all show the same chrome (Dashboard / Install CLI / Profile / Users / email + avatar / Logout) with the active page highlighted via request.url.path. Old inline header CSS (.header, .top-header, .page-header, .nav-link, etc.) is left in place as harmless dead code; it can be cleaned up in a follow-up sweep. * feat(web): add readable preview of Claude setup payload on dashboard + /install Move the line-by-line setup instructions into app/web/setup_instructions.py as the single source of truth, then render them in two modes from the existing _claude_setup_instructions.jinja partial: - preview_mode=True → visible, read-only
 block with the real
  server URL and a clearly-styled placeholder token (never a real one).
- preview_mode=False → the JS SETUP_INSTRUCTIONS_TEMPLATE used by the
  one-click flow (unchanged behaviour).

Both /dashboard (env-setup-cta card) and /install (Step 1 card) now show
the preview directly under the 'Setup a new Claude Code' button so users
can see exactly what will land in their clipboard before they click.

* feat(web): update setup instructions — `da diagnose` step, explicit section titles

Rework the Claude Code setup payload to:

- Give every numbered step an unambiguous verb header ("1) Install the CLI",
  "2) Log in", "3) Verify the login", "4) Run diagnostics", "5) Skills (ask
  the user first)", "6) Confirm").
- Add step 4 `da diagnose` as the post-login health check. The CLI already
  ships this command (cli/commands/diagnose.py); it prints "Overall:
  healthy" and a list of green checks that map cleanly to next actions.
- Ask the skills copy-vs-on-demand question verbatim so Claude Code always
  prompts the user the same way.
- Replace the terse "Confirm" line with a 4-bullet summary (version,
  whoami, skills choice, diagnose status) so the return message is
  structured and comparable across setups.

* chore(web): remove stale MCP card from /install (no MCP server today)

The 'Use with Claude Code / MCP' card (Step 3 on /install) referenced an
MCP integration Agnes does not ship. Remove the whole card. The one-click
'Setup a new Claude Code' flow in Step 1 already covers the long-lived
client use case and is less confusing than dangling persistence tips for
a non-existent integration.

* feat(api): include user_email + last_used_ip + user_id in admin tokens list response

Adds AdminTokenItem response model (superset of TokenListItem) and
AccessTokenRepository.list_all_with_user() joining personal_access_tokens
with users to denormalize user_email. Needed for /admin/tokens UI where
admins triage tokens across all users.

* feat(web): /admin/tokens page — list, filter, search, revoke across all users

Adds a new admin-only page with client-side filtering (status, user email,
last-used window), column sorting, counts bar (active/revoked/expired),
and an inline revoke action. Mirrors the /admin/users visual language.

* feat(web): add Tokens nav link for admins + deep-link from admin/users row

Admin-only nav entry to /admin/tokens, and a per-row Tokens button on
/admin/users that prefills the token page's user filter via ?user=.

* test(admin): cover /admin/tokens rendering, filter state, non-admin denial, revoke

Verifies admin can render the page (title + JS hooks present), a non-admin
is blocked, unauthenticated users are redirected, the admin list response
includes user_email / user_id / last_used_ip, and admin can revoke another
user's token.

* feat(web): modern redesign of /admin/tokens — hero, stat strip, refined table, responsive cards, a11y

* feat(web): ditch the table — /admin/tokens as a card stack, modern GitHub-style list

Replaces the table-based layout with a stack of self-contained token cards
inside a 
    . Each card is a flex row: avatar + name/meta on the left, last-used block in the middle, status pill + outlined 'Revoke' button on the right. Status and sort controls are pill-shaped toggle chips; user email search has an inline search icon. No //
    / anywhere. Responsive below 720px (card stacks vertically) and 480px (stat chips 2x2). Preserves filter IDs (flt-status, flt-user, flt-last-used) and data-revoke for existing tests. * feat(web): add /tokens (role-aware) — single page for both user PAT CRUD and admin overview - Rename admin_tokens.html -> tokens.html with a new is_admin context flag. - New route GET /tokens: renders the same card-stack UI for everyone. * Admins: loads /auth/admin/tokens, shows owner column + stat strip, keeps the owner-email search box and sort-by-owner chip. * Non-admins: loads /auth/tokens (own tokens only), hides owner column + stat chips, adds a 'New token' CTA in the hero that opens a modal (name + expires_in_days) calling POST /auth/tokens. The raw token is revealed once in a dismissable banner and cleared from the DOM on Hide. - GET /admin/tokens now 302-redirects to /tokens, preserving query string (so the /admin/users deep-link ?user=foo still works). * feat(web): /tokens full-bleed layout to match dashboard width The hero, toolbar, and card list used to sit inside base.html's .container (max-width 800px). Break out with negative horizontal margins so the page spans the viewport like /dashboard does, capped at 1440px for readability on very wide screens with a 24px gutter on each side. - No change to base.html itself. The override is scoped to .tokens-page. - body { overflow-x: hidden; } guards against rare horizontal scrollbars. - < 808px viewport: reset to natural flow (mobile already narrower). - ≥ 1488px viewport: cap to 1440px and re-center. * chore(web): remove /profile template + nav link (redirect /profile -> /tokens) The old /profile PAT CRUD page is now redundant — the modern /tokens page covers both user and admin flows. Delete the template; the router's /profile handler already 302-redirects to /tokens. Nav cleanup: - Remove the 'Profile' link. - Show a single 'Tokens' link to every signed-in user (previously only admins saw it). - Active-state matches /tokens, /admin/tokens, and /profile so the highlight survives the redirect chain. /install CTA now points at /tokens instead of /profile. * test: cover /tokens for admin + non-admin flows, /profile redirect, nav update tests/test_admin_tokens_ui.py - Point admin rendering test at /tokens directly and tighten assertions (admin-only stat strip + owner search, non-admin CTA absent). - Add test_non_admin_can_render_tokens_page: personal body, New-token CTA, create-modal, reveal banner; stat strip + owner search absent. - Add test_admin_tokens_redirects_to_tokens: 302 to /tokens, query string (?user=...) preserved for the /admin/users deep-link. - Add test_profile_redirects_to_tokens: 302 to /tokens. - Add test_non_admin_can_create_pat_via_tokens_page_api: exercises the POST /auth/tokens call that the non-admin create-modal submits. tests/test_pat.py - test_profile_page_renders -> test_profile_page_redirects_to_tokens: assert the 302 + that /tokens lands on the unified non-admin body. tests/test_web_ui.py - admin_users nav assertion: 'Tokens' link present, 'Profile' link absent. - Add test_nav_shows_tokens_link_for_non_admin: non-admins see the same 'Tokens' link (previously only admins did). - Add test_profile_redirects_to_tokens back-compat check. * feat(web): collapse 'What Claude Code will receive' by default The preview block on /dashboard and /install now uses
    / so it is hidden by default. Click the chevron/title to expand and review the clipboard payload. Markup stays in the DOM so existing tests that assert on content continue to pass. * fix(web): /tokens width — override .container to 1280px like dashboard The negative-margin full-bleed trick was fragile and pushed content past the right edge on deployed viewports. Replace with a simple max-width override of base.html's .container on this page only, matching /dashboard's 1280px center-column layout. * feat(web): split role-aware /tokens into my_tokens.html + admin_tokens.html * feat(web): router — separate handlers for /tokens (own) and /admin/tokens (all) * feat(web): nav — show Tokens for all, add All tokens for admins * test: cover split token pages (own vs all) + admin access gating * feat(web): move 'My tokens' into a user dropdown menu Replaces the separate Tokens/email/Logout nav trio with a rounded avatar trigger that opens a dropdown containing the user's email, role, a 'My tokens' link, and Logout. Admin-only 'All tokens' stays as a top-level nav item since it's an admin function, not a personal one. Click-outside and Escape close the panel; chevron rotates on open. * fix(api): allow PATs to list/get/revoke their own tokens (CLI flow) The documented 'da auth token list/revoke' CLI flow in docs/HEADLESS_USAGE.md uses a PAT, but the previous dependency (require_session_token) returned 403. Only create_token must be session-only to prevent PAT-spawning-PAT chains; listing and revoking your own tokens is safe with a PAT. * fix(api): cap expires_in_days at 3650 to avoid datetime overflow (500 to 400) Values above ~11 million days overflowed datetime.max in datetime.now(utc) + timedelta(days=...) and surfaced as an unhandled OverflowError → 500. Cap at 10 years with a clear 400 instead; the no-expiry code path is unaffected. * fix(api): relax _SAFE_URL_RE to allow path prefixes, underscores, and IPv6 The previous regex rejected legitimate reverse-proxy base_url values (https://host/agnes/), underscores in Docker Compose hostnames, and IPv6 literals (http://[::1]:8000). Widen the charset and allow an optional trailing path. shlex.quote continues to provide defense-in-depth against any metacharacter that slips through. * fix(web): /login/email and Google OAuth propagate next_path Previously, /login/email silently dropped the ?next= query param so the hidden form field rendered empty and login always landed on /dashboard. Google's button was hard-coded to /auth/google/login, ignoring next entirely. - /login page now appends ?next to the Google button URL - /login/email reads + sanitizes next, passes as template context - google_login stashes sanitized next_path in session['login_next'] - google_callback pops + re-sanitizes and redirects there Sanitization factored into app/auth/_common.safe_next_path. * fix(auth): differentiate argon2 VerifyMismatchError from internal errors in web login The previous except (VerifyMismatchError, Exception) collapsed both cases into the generic 'invalid credentials' redirect, silently hiding corrupted-hash / library errors from ops. Split the two: bad password still gets ?error=invalid; anything else logs via logger.exception and redirects with ?err=auth_internal so ops have a visible signal and users don't retry forever against a broken password_hash column. * docs: correct CLAUDE.md table name (personal_access_tokens) v7 note referenced 'access_tokens.last_used_ip' but the real table is personal_access_tokens (as mentioned two tokens earlier in the same bullet). Same-file consistency fix. * chore(web): clarify admin user-reset UI — encourage Set password over the unused reset_token POST /api/users/{id}/reset-password stores and returns a token but no endpoint consumes it — the magic-link sender would log the user in without prompting for a new password, defeating the reset. - Drop the 'Reset' row action from admin_users so admins aren't pointed at a dead end. - Rewrite the reveal-modal copy to tell admins to use Set password and explicitly note that the magic-link flow isn't available for reset tokens in this build. The API endpoint stays for API-level future use. * test: cover PAT CLI flow, expires_in_days overflow, proxy base_url, next propagation - tests/test_pat.py: PAT can list own tokens (200, was 403); PAT can revoke own tokens (204); create_token returns 400 for expires_in_days > 3650 (was 500 via datetime overflow). - tests/test_cli_artifacts.py: _SAFE_URL_RE accepts reverse-proxy path prefixes, underscores, and IPv6 literals; end-to-end check of cli_install_script with a stubbed base_url that includes a path prefix (Agnes behind /agnes/). - tests/test_web_ui.py: /login propagates ?next to the Google button URL; /login/email renders next in the hidden form field and strips hostile values; unit coverage of safe_next_path. * fix(security): use \Z instead of $ in URL/version allowlists (trailing-\n bypass) Python regex `$` also matches just before a trailing newline, so a Host header or AGNES_VERSION value like "good.example.com\n$(rm -rf /)" would slip past the allowlist. `\Z` anchors to strict end-of-string. shlex.quote downstream remains as defense-in-depth, but the allowlist is now the tight gate it claims to be. * fix(auth): PAT with null expiry omits JWT exp claim (DB is the source of truth) Previously a PAT created with `expires_in_days=null` (user-requested "never expires") set the DB `expires_at` to NULL (correct) but still baked a ~100y `exp` claim into the JWT. That is misleading: the PAT silently did expire eventually, despite the UI and API promising "no expiry". `create_access_token` now accepts `omit_exp=True` to skip the `exp` claim entirely. `app/api/tokens.py` passes that when `expires_in_days is None`. The authoritative expiry check lives in `app/auth/dependencies.py`, which reads `expires_at` from the DB row — unchanged. PyJWT accepts claim-less JWTs indefinitely. * test: cover trailing-newline regex bypass + no-exp JWT for unbounded PAT - test_safe_url_re_rejects_trailing_newline_bypass: asserts both `_SAFE_URL_RE` and `_SAFE_VERSION_RE` reject values with a trailing `\n` (previously accepted because Python `$` matches before `\n`). - test_pat_null_expiry_jwt_has_no_exp_claim: POST /auth/tokens with `expires_in_days=null`, decode the returned JWT, assert `exp` is absent while `typ=pat`, `sub`, and `jti` are still present. - test_pat_with_null_expiry_is_accepted_by_verify_token: verify_token round-trips a claim-less JWT without ExpiredSignatureError. - test_pat_null_expiry_end_to_end_allows_authenticated_request: use the null-expiry PAT against /auth/tokens and confirm it authenticates. * docs(auth): document X-Forwarded-For trust model in _client_ip Deployment runs behind Caddy which strips incoming X-Forwarded-For and sets its own, so the leftmost hop is trustworthy. Clarify that the stored last_used_ip is audit-only and never used for access control — if the app is ever exposed directly, this value becomes client-settable. * docs: /profile → /tokens in install.sh next-steps, CLI error, HEADLESS_USAGE, security skill After splitting PAT management to /tokens (with /profile as a back-compat 302), stale references remained in user-facing text. Update them to the canonical /tokens URL so shell scripts, CLI error hints, docs, and the bundled security skill are all consistent. --- CLAUDE.md | 2 +- Dockerfile | 7 +- app/api/cli_artifacts.py | 140 + app/api/tokens.py | 197 ++ app/api/users.py | 216 +- app/auth/_common.py | 24 + app/auth/dependencies.py | 103 + app/auth/jwt.py | 22 +- app/auth/providers/google.py | 27 +- app/auth/providers/password.py | 20 +- app/auth/router.py | 3 + app/main.py | 28 + app/web/router.py | 102 +- app/web/setup_instructions.py | 79 + app/web/static/style-custom.css | 161 + app/web/static/style.css | 3 + app/web/templates/_app_header.html | 67 + .../_claude_setup_instructions.jinja | 44 + app/web/templates/activity_center.html | 22 +- app/web/templates/admin_permissions.html | 20 +- app/web/templates/admin_tables.html | 20 +- app/web/templates/admin_tokens.html | 1202 +++++++ app/web/templates/admin_users.html | 553 +++ app/web/templates/base.html | 20 +- app/web/templates/catalog.html | 19 +- app/web/templates/corporate_memory.html | 44 +- app/web/templates/corporate_memory_admin.html | 29 +- app/web/templates/dashboard.html | 326 +- app/web/templates/install.html | 1077 ++++++ app/web/templates/login_email.html | 1 + app/web/templates/my_tokens.html | 1294 +++++++ cli/client.py | 5 + cli/commands/admin.py | 96 +- cli/commands/auth.py | 163 +- cli/commands/tokens.py | 97 + cli/skills/security.md | 5 +- docs/HEADLESS_USAGE.md | 45 + .../plans/2026-04-09-dead-code-cleanup.md | 259 ++ .../plans/2026-04-09-deployment-readiness.md | 495 +++ .../plans/2026-04-09-final-polish.md | 187 + .../plans/2026-04-09-security-fixes.md | 623 ++++ .../plans/2026-04-21-hackathon-dry-run.md | 848 +++++ .../plans/2026-04-21-issues-14-and-10.md | 490 +++ .../plans/2026-04-21-user-mgmt-pat-cli.md | 3143 +++++++++++++++++ .../2026-04-22-cloudflare-access-auth.md | 1411 ++++++++ .../plans/2026-04-22-grpn-deploy-learnings.md | 79 + .../specs/2026-04-14-connector-kit-design.md | 1308 +++++++ scripts/grpn/Makefile | 146 + scripts/grpn/README.md | 179 + scripts/grpn/agnes-auto-upgrade.sh | 18 + src/db.py | 59 +- src/repositories/access_tokens.py | 96 + src/repositories/users.py | 14 +- tests/test_admin_tokens_ui.py | 420 +++ tests/test_cli_admin.py | 30 + tests/test_cli_artifacts.py | 139 + tests/test_cli_auth.py | 165 +- tests/test_connector_kit_poc.py | 959 +++++ tests/test_pat.py | 710 ++++ tests/test_user_management.py | 298 ++ tests/test_web_ui.py | 254 ++ 61 files changed, 18344 insertions(+), 269 deletions(-) create mode 100644 app/api/cli_artifacts.py create mode 100644 app/api/tokens.py create mode 100644 app/auth/_common.py create mode 100644 app/web/setup_instructions.py create mode 100644 app/web/templates/_app_header.html create mode 100644 app/web/templates/_claude_setup_instructions.jinja create mode 100644 app/web/templates/admin_tokens.html create mode 100644 app/web/templates/admin_users.html create mode 100644 app/web/templates/install.html create mode 100644 app/web/templates/my_tokens.html create mode 100644 cli/commands/tokens.py create mode 100644 docs/HEADLESS_USAGE.md create mode 100644 docs/superpowers/plans/2026-04-09-dead-code-cleanup.md create mode 100644 docs/superpowers/plans/2026-04-09-deployment-readiness.md create mode 100644 docs/superpowers/plans/2026-04-09-final-polish.md create mode 100644 docs/superpowers/plans/2026-04-09-security-fixes.md create mode 100644 docs/superpowers/plans/2026-04-21-hackathon-dry-run.md create mode 100644 docs/superpowers/plans/2026-04-21-issues-14-and-10.md create mode 100644 docs/superpowers/plans/2026-04-21-user-mgmt-pat-cli.md create mode 100644 docs/superpowers/plans/2026-04-22-cloudflare-access-auth.md create mode 100644 docs/superpowers/plans/2026-04-22-grpn-deploy-learnings.md create mode 100644 docs/superpowers/specs/2026-04-14-connector-kit-design.md create mode 100644 scripts/grpn/Makefile create mode 100644 scripts/grpn/README.md create mode 100755 scripts/grpn/agnes-auto-upgrade.sh create mode 100644 src/repositories/access_tokens.py create mode 100644 tests/test_admin_tokens_ui.py create mode 100644 tests/test_cli_artifacts.py create mode 100644 tests/test_connector_kit_poc.py create mode 100644 tests/test_pat.py create mode 100644 tests/test_user_management.py diff --git a/CLAUDE.md b/CLAUDE.md index 1f17e78..d0e900e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,7 @@ Auth providers in `app/auth/` (FastAPI-based): ## Key Implementation Details ### DuckDB Schema (src/db.py) -- Schema v4 with auto-migration from v1→v2→v3→v4 +- Schema v7 with auto-migration from v1→v2→v3→v4→v5→v6→v7 (v5 adds `users.active`, v6 adds `personal_access_tokens`, v7 adds `personal_access_tokens.last_used_ip`) - `table_registry`: id, name, source_type, bucket, source_table, query_mode, sync_schedule, etc. - `sync_state`, `sync_history`: track extraction progress - `users`, `dataset_permissions`, `audit_log`: auth + RBAC diff --git a/Dockerfile b/Dockerfile index 977204b..4b88690 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,7 @@ FROM python:3.13-slim -# Install curl for healthcheck RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* -# Install uv for fast dependency management COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv ARG AGNES_VERSION=dev @@ -15,12 +13,13 @@ ENV AGNES_COMMIT_SHA=${AGNES_COMMIT_SHA} WORKDIR /app -# Copy application code COPY . . +# Build wheel artifact (served at /cli/download) +RUN uv build --wheel --out-dir /app/dist + # Install production dependencies from pyproject.toml RUN uv pip install --system --no-cache . -# Default: run FastAPI server EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/api/cli_artifacts.py b/app/api/cli_artifacts.py new file mode 100644 index 0000000..d61d049 --- /dev/null +++ b/app/api/cli_artifacts.py @@ -0,0 +1,140 @@ +"""CLI artifact download + install script endpoints (#9).""" + +import os +import re +import shlex +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, PlainTextResponse + +# Strict allowlists for values interpolated into the generated install.sh. +# The endpoint is unauthenticated and users `curl | bash` it, so any shell +# metacharacter leaking through the Host header or AGNES_VERSION env var +# would become RCE. `shlex.quote` is applied on top for defense in depth. +# +# Host charset allows underscores (Docker Compose hostnames) and `[` `]` `:` +# so IPv6 literals like http://[::1]:8000 pass. Optional trailing path lets +# reverse-proxy deployments (request.base_url = "https://host/agnes/") work. +# +# `\Z` (not `$`) anchors strictly to end-of-string. Python's `$` also matches +# immediately before a trailing `\n`, which would let a crafted Host header +# like "good.example.com\n$(rm -rf /)" slip past the allowlist. `\Z` closes +# that bypass — shlex.quote downstream is still defense-in-depth. +_SAFE_URL_RE = re.compile(r"^https?://[A-Za-z0-9._\-\[\]:]+(:\d+)?(/[A-Za-z0-9._\-/]*)?\Z") +_SAFE_VERSION_RE = re.compile(r"^[A-Za-z0-9._\-]+\Z") + +router = APIRouter(tags=["cli"]) + + +def _dist_dir() -> Path: + return Path(os.environ.get("AGNES_CLI_DIST_DIR", "/app/dist")) + + +def _find_wheel() -> Path | None: + d = _dist_dir() + if not d.exists(): + return None + wheels = sorted(d.glob("*.whl")) + return wheels[-1] if wheels else None + + +@router.get("/cli/download") +async def cli_download(): + wheel = _find_wheel() + if not wheel: + raise HTTPException( + status_code=404, + detail=( + "CLI wheel not found in dist dir. Build it with `uv build --wheel` " + "or run the official docker image (which builds on image-build)." + ), + ) + return FileResponse( + path=str(wheel), + filename=wheel.name, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{wheel.name}"'}, + ) + + +@router.get("/cli/agnes.whl") +async def cli_wheel_stable(): + """Stable `.whl` URL alias so `uv tool install /cli/agnes.whl` works. + + `uv tool install` inspects the URL path to decide how to treat the resource + and only accepts it as a wheel when the path ends in `.whl`. The existing + `/cli/download` path does not, which forces users through a multi-step + curl + tmpfile + install + rm dance. This alias collapses that into a + single `uv tool install` invocation. + """ + wheel = _find_wheel() + if not wheel: + raise HTTPException( + status_code=404, + detail=( + "CLI wheel not found in dist dir. Build it with `uv build --wheel` " + "or run the official docker image (which builds on image-build)." + ), + ) + return FileResponse( + path=str(wheel), + filename=wheel.name, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{wheel.name}"'}, + ) + + +@router.get("/cli/install.sh", response_class=PlainTextResponse) +async def cli_install_script(request: Request): + """Shell installer — bakes this server's URL into the generated config.""" + base_url = str(request.base_url).rstrip("/") + if not _SAFE_URL_RE.match(base_url): + raise HTTPException(status_code=400, detail="Unexpected server URL format") + version = os.environ.get("AGNES_VERSION", "dev") + if not _SAFE_VERSION_RE.match(version): + version = "dev" + # shlex.quote hardens against anything that slipped past the regex + server_q = shlex.quote(base_url) + version_q = shlex.quote(version) + script = f"""#!/usr/bin/env bash +# Agnes CLI installer — server: {base_url} +set -euo pipefail + +SERVER={server_q} +echo "Installing Agnes CLI from $SERVER (version: {version_q})" + +# 1. Download the wheel +# Portable mktemp: X's must be at the end of the template on both GNU and BSD/macOS. +TMPDIR_WHEEL=$(mktemp -d -t agnes_cli.XXXXXX) +trap 'rm -rf "$TMPDIR_WHEEL"' EXIT +# Use -OJ so curl honours Content-Disposition and saves the wheel with its real +# PEP-427 filename (pip / uv tool install reject filenames without a version). +(cd "$TMPDIR_WHEEL" && curl -fsSL -OJ "$SERVER/cli/download") +WHEEL=$(ls "$TMPDIR_WHEEL"/*.whl 2>/dev/null | head -n1) +if [ -z "$WHEEL" ]; then + echo "error: wheel download failed (no .whl found in $TMPDIR_WHEEL)" >&2 + exit 1 +fi + +# 2. Install via pip (prefer uv tool install if available) +if command -v uv >/dev/null 2>&1; then + uv tool install --force "$WHEEL" +else + python3 -m pip install --user --force-reinstall "$WHEEL" +fi + +# 3. Seed the server URL in CLI config +CFG_DIR="${{DA_CONFIG_DIR:-$HOME/.config/da}}" +mkdir -p "$CFG_DIR" +cat > "$CFG_DIR/config.yaml" <" +echo " 3. Verify: da auth whoami" +""" + return script diff --git a/app/api/tokens.py b/app/api/tokens.py new file mode 100644 index 0000000..7899453 --- /dev/null +++ b/app/api/tokens.py @@ -0,0 +1,197 @@ +"""Personal access token endpoints (#12).""" + +import hashlib +import secrets +import uuid +from datetime import datetime, timezone, timedelta +from typing import Optional, List + +import duckdb +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from app.auth.dependencies import require_session_token, require_role, get_current_user, _get_db +from src.rbac import Role +from src.repositories.access_tokens import AccessTokenRepository +from src.repositories.audit import AuditRepository +from app.auth.jwt import create_access_token + +router = APIRouter(prefix="/auth/tokens", tags=["tokens"]) +admin_router = APIRouter(prefix="/auth/admin/tokens", tags=["tokens-admin"]) + + +class CreateTokenRequest(BaseModel): + name: str + expires_in_days: Optional[int] = 90 # null = no expiry + + +class CreateTokenResponse(BaseModel): + id: str + name: str + prefix: str + token: str # raw token — returned exactly once + expires_at: Optional[str] + created_at: str + + +class TokenListItem(BaseModel): + id: str + name: str + prefix: str + created_at: str + expires_at: Optional[str] + last_used_at: Optional[str] + revoked_at: Optional[str] + + +class AdminTokenItem(TokenListItem): + """Admin list row: adds owner identity + last IP for incident response.""" + user_id: str + user_email: Optional[str] = None + last_used_ip: Optional[str] = None + + +def _audit(conn, actor: str, action: str, target: str, params=None): + try: + AuditRepository(conn).log(user_id=actor, action=action, + resource=f"token:{target}", params=params) + except Exception: + pass + + +def _row_to_item(row: dict) -> TokenListItem: + return TokenListItem( + id=row["id"], name=row["name"], prefix=row["prefix"], + created_at=str(row.get("created_at") or ""), + expires_at=str(row["expires_at"]) if row.get("expires_at") else None, + last_used_at=str(row["last_used_at"]) if row.get("last_used_at") else None, + revoked_at=str(row["revoked_at"]) if row.get("revoked_at") else None, + ) + + +def _row_to_admin_item(row: dict) -> AdminTokenItem: + return AdminTokenItem( + id=row["id"], name=row["name"], prefix=row["prefix"], + created_at=str(row.get("created_at") or ""), + expires_at=str(row["expires_at"]) if row.get("expires_at") else None, + last_used_at=str(row["last_used_at"]) if row.get("last_used_at") else None, + revoked_at=str(row["revoked_at"]) if row.get("revoked_at") else None, + user_id=row.get("user_id") or "", + user_email=row.get("user_email"), + last_used_ip=row.get("last_used_ip"), + ) + + +@router.post("", response_model=CreateTokenResponse, status_code=201) +async def create_token( + payload: CreateTokenRequest, + user: dict = Depends(require_session_token), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + if not payload.name.strip(): + raise HTTPException(status_code=400, detail="name is required") + if payload.expires_in_days is not None and payload.expires_in_days <= 0: + raise HTTPException(status_code=400, detail="expires_in_days must be a positive integer") + # Cap at 10 years — larger values overflow datetime.max during the + # `datetime.now(utc) + timedelta(days=...)` addition and surface as an + # unhandled OverflowError → 500. 10y is well past any legitimate PAT + # lifetime (the no-expiry path below uses ~100y and doesn't compute + # expires_at on the datetime object). + if payload.expires_in_days is not None and payload.expires_in_days > 3650: + raise HTTPException(status_code=400, detail="expires_in_days must not exceed 3650 (10 years)") + repo = AccessTokenRepository(conn) + token_id = str(uuid.uuid4()) + expires_at: Optional[datetime] = None + expires_delta: Optional[timedelta] = None + omit_exp = payload.expires_in_days is None + if payload.expires_in_days is not None: + expires_delta = timedelta(days=payload.expires_in_days) + expires_at = datetime.now(timezone.utc) + expires_delta + # else: "no expiry" — DB stores expires_at=NULL and the JWT carries no + # `exp` claim. The authoritative expiry check lives in + # app/auth/dependencies.py (via the DB row). + # Build the JWT that embeds jti=token_id and typ=pat + jwt_token = create_access_token( + user_id=user["id"], email=user["email"], role=user["role"], + token_id=token_id, typ="pat", + expires_delta=expires_delta, omit_exp=omit_exp, + ) + # Prefix: first 8 chars of the jti (UUID) — uniquely identifies the token in UI + # without exposing JWT headers (which all start with "eyJhbGci…" and are useless + # for identification). The JWT itself is returned ONCE in the response body. + prefix = token_id.replace("-", "")[:8] + # token_hash = sha256(raw JWT). Used in verify_token as defense-in-depth. + token_hash = hashlib.sha256(jwt_token.encode()).hexdigest() + repo.create( + id=token_id, user_id=user["id"], name=payload.name.strip(), + token_hash=token_hash, prefix=prefix, expires_at=expires_at, + ) + _audit(conn, user["id"], "token.create", token_id, {"name": payload.name}) + return CreateTokenResponse( + id=token_id, name=payload.name.strip(), prefix=prefix, + token=jwt_token, # returned EXACTLY ONCE; never retrievable again + expires_at=str(expires_at) if expires_at else None, + created_at=str(datetime.now(timezone.utc)), + ) + + +@router.get("", response_model=List[TokenListItem]) +async def list_tokens( + user: dict = Depends(get_current_user), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + # PATs may list their owner's own tokens — required by the documented + # `da auth token list` CLI flow (HEADLESS_USAGE.md). Only `create_token` + # is session-only (to block PAT-spawning-PAT chains). + rows = AccessTokenRepository(conn).list_for_user(user["id"]) + return [_row_to_item(r) for r in rows] + + +@router.get("/{token_id}", response_model=TokenListItem) +async def get_token( + token_id: str, + user: dict = Depends(get_current_user), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + row = AccessTokenRepository(conn).get_by_id(token_id) + if not row or row["user_id"] != user["id"]: + raise HTTPException(status_code=404, detail="Token not found") + return _row_to_item(row) + + +@router.delete("/{token_id}", status_code=204) +async def revoke_token( + token_id: str, + user: dict = Depends(get_current_user), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + repo = AccessTokenRepository(conn) + row = repo.get_by_id(token_id) + if not row or row["user_id"] != user["id"]: + raise HTTPException(status_code=404, detail="Token not found") + repo.revoke(token_id) + _audit(conn, user["id"], "token.revoke", token_id) + + +# Admin — list & revoke tokens across users (for incident response) + +@admin_router.get("", response_model=List[AdminTokenItem]) +async def admin_list_tokens( + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + return [_row_to_admin_item(r) for r in AccessTokenRepository(conn).list_all_with_user()] + + +@admin_router.delete("/{token_id}", status_code=204) +async def admin_revoke_token( + token_id: str, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + repo = AccessTokenRepository(conn) + row = repo.get_by_id(token_id) + if not row: + raise HTTPException(status_code=404, detail="Token not found") + repo.revoke(token_id) + _audit(conn, user["id"], "token.admin_revoke", token_id, {"owner_id": row["user_id"]}) diff --git a/app/api/users.py b/app/api/users.py index 1bfae1f..79ac8d7 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -1,19 +1,42 @@ -"""User management endpoints.""" +"""User management endpoints (#11).""" import uuid - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from datetime import datetime, timezone from typing import Optional, List import duckdb +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from argon2 import PasswordHasher from app.auth.dependencies import require_role, Role, _get_db from src.repositories.users import UserRepository +from src.repositories.audit import AuditRepository router = APIRouter(prefix="/api/users", tags=["users"]) +def _audit(conn: duckdb.DuckDBPyConnection, actor_id: str, action: str, target_id: str, params: Optional[dict] = None) -> None: + try: + # Convert non-JSON-serializable values (datetime) to strings first + safe_params = None + if params: + safe_params = {} + for k, v in params.items(): + if isinstance(v, datetime): + safe_params[k] = v.isoformat() + else: + safe_params[k] = v + AuditRepository(conn).log( + user_id=actor_id, + action=action, + resource=f"user:{target_id}", + params=safe_params, + ) + except Exception: + pass # never block the endpoint on audit failure + + class CreateUserRequest(BaseModel): email: str name: str @@ -23,6 +46,11 @@ class CreateUserRequest(BaseModel): class UpdateUserRequest(BaseModel): name: Optional[str] = None role: Optional[str] = None + active: Optional[bool] = None + + +class SetPasswordRequest(BaseModel): + password: str class UserResponse(BaseModel): @@ -30,7 +58,21 @@ class UserResponse(BaseModel): email: str name: Optional[str] role: str + active: bool = True created_at: Optional[str] + deactivated_at: Optional[str] = None + + +def _to_response(u: dict) -> UserResponse: + return UserResponse( + id=u["id"], + email=u["email"], + name=u.get("name"), + role=u["role"], + active=bool(u.get("active", True)), + created_at=str(u.get("created_at", "")), + deactivated_at=str(u["deactivated_at"]) if u.get("deactivated_at") else None, + ) @router.get("", response_model=List[UserResponse]) @@ -38,37 +80,177 @@ async def list_users( user: dict = Depends(require_role(Role.ADMIN)), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): - repo = UserRepository(conn) - users = repo.list_all() - return [ - UserResponse( - id=u["id"], email=u["email"], name=u.get("name"), - role=u["role"], created_at=str(u.get("created_at", "")), - ) for u in users - ] + return [_to_response(u) for u in UserRepository(conn).list_all()] @router.post("", response_model=UserResponse, status_code=201) async def create_user( - request: CreateUserRequest, + payload: CreateUserRequest, + request: Request, user: dict = Depends(require_role(Role.ADMIN)), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): repo = UserRepository(conn) - if repo.get_by_email(request.email): + if repo.get_by_email(payload.email): raise HTTPException(status_code=409, detail="User with this email already exists") + try: + Role(payload.role) + except ValueError: + raise HTTPException(status_code=400, detail=f"Unknown role: {payload.role}") user_id = str(uuid.uuid4()) - repo.create(id=user_id, email=request.email, name=request.name, role=request.role) - return UserResponse(id=user_id, email=request.email, name=request.name, role=request.role, created_at=None) + repo.create(id=user_id, email=payload.email, name=payload.name, role=payload.role) + _audit(conn, user["id"], "user.create", user_id, {"email": payload.email, "role": payload.role}) + created = repo.get_by_id(user_id) + return _to_response(created) + + +@router.patch("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: str, + payload: UpdateUserRequest, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + repo = UserRepository(conn) + target = repo.get_by_id(user_id) + if not target: + raise HTTPException(status_code=404, detail="User not found") + + updates: dict = {} + if payload.name is not None: + updates["name"] = payload.name + if payload.role is not None: + # Validate role is a known value + try: + Role(payload.role) + except ValueError: + raise HTTPException(status_code=400, detail=f"Unknown role: {payload.role}") + # Protect: don't let admin demote themselves if they are the last admin + if ( + target["id"] == user["id"] + and target["role"] == "admin" + and payload.role != "admin" + and repo.count_admins(active_only=True) <= 1 + ): + raise HTTPException(status_code=409, detail="Cannot demote the last active admin") + updates["role"] = payload.role + if payload.active is not None: + # Protect: cannot self-deactivate + if target["id"] == user["id"] and payload.active is False: + raise HTTPException(status_code=409, detail="Cannot deactivate yourself") + # Protect: cannot deactivate the last active admin + if ( + target.get("role") == "admin" + and payload.active is False + and repo.count_admins(active_only=True) <= 1 + ): + raise HTTPException(status_code=409, detail="Cannot deactivate the last active admin") + updates["active"] = payload.active + if payload.active is False: + updates["deactivated_at"] = datetime.now(timezone.utc) + updates["deactivated_by"] = user["id"] + else: + updates["deactivated_at"] = None + updates["deactivated_by"] = None + + if updates: + repo.update(id=user_id, **updates) + _audit(conn, user["id"], "user.update", user_id, {k: v for k, v in updates.items() if k != "deactivated_at"}) + return _to_response(repo.get_by_id(user_id)) @router.delete("/{user_id}", status_code=204) async def delete_user( user_id: str, + request: Request, user: dict = Depends(require_role(Role.ADMIN)), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): repo = UserRepository(conn) - if not repo.get_by_id(user_id): + target = repo.get_by_id(user_id) + if not target: raise HTTPException(status_code=404, detail="User not found") + if target["id"] == user["id"]: + raise HTTPException(status_code=409, detail="Cannot delete yourself") + if target.get("role") == "admin" and repo.count_admins(active_only=True) <= 1: + raise HTTPException(status_code=409, detail="Cannot delete the last active admin") repo.delete(user_id) + _audit(conn, user["id"], "user.delete", user_id, {"email": target["email"]}) + + +@router.post("/{user_id}/reset-password") +async def reset_password( + user_id: str, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Generate a reset token and (best-effort) email it to the user.""" + import secrets + repo = UserRepository(conn) + target = repo.get_by_id(user_id) + if not target: + raise HTTPException(status_code=404, detail="User not found") + token = secrets.token_urlsafe(32) + repo.update( + id=user_id, + reset_token=token, + reset_token_created=datetime.now(timezone.utc), + ) + _audit(conn, user["id"], "user.reset_password", user_id, {"email": target["email"]}) + # Intentionally do NOT auto-send an email. The magic-link sender + # (`app/auth/providers/email.py:_send_email`) would deliver a "Login Link" + # that — when clicked — consumes the reset_token via verify_magic_link and + # logs the user in WITHOUT prompting for a new password, defeating the + # reset. Until a dedicated password-reset email flow with its own token + # column exists, admins share the `reset_token` below manually (or use the + # `set-password` endpoint directly). + return {"reset_token": token, "email_sent": False} + + +@router.post("/{user_id}/set-password", status_code=204) +async def set_password( + user_id: str, + payload: SetPasswordRequest, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + if not payload.password or len(payload.password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + repo = UserRepository(conn) + target = repo.get_by_id(user_id) + if not target: + raise HTTPException(status_code=404, detail="User not found") + ph = PasswordHasher() + repo.update(id=user_id, password_hash=ph.hash(payload.password)) + _audit(conn, user["id"], "user.set_password", user_id, {"email": target["email"]}) + + +@router.post("/{user_id}/deactivate", response_model=UserResponse) +async def deactivate_user( + user_id: str, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + return await update_user( + user_id=user_id, + payload=UpdateUserRequest(active=False), + request=request, user=user, conn=conn, + ) + + +@router.post("/{user_id}/activate", response_model=UserResponse) +async def activate_user( + user_id: str, + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + return await update_user( + user_id=user_id, + payload=UpdateUserRequest(active=True), + request=request, user=user, conn=conn, + ) diff --git a/app/auth/_common.py b/app/auth/_common.py new file mode 100644 index 0000000..219e114 --- /dev/null +++ b/app/auth/_common.py @@ -0,0 +1,24 @@ +"""Shared helpers for auth providers (Google OAuth, password, email link). + +Kept out of `dependencies.py` so it doesn't pull FastAPI auth machinery into +thin provider modules that only need the sanitizer. +""" + +from typing import Optional + + +def safe_next_path(candidate: Optional[str], default: str = "/dashboard") -> str: + """Return `candidate` if it's a same-origin absolute path, else `default`. + + Open-redirect guard: must start with a single `/` and must NOT start with + `//` (which browsers treat as protocol-relative, i.e. cross-origin). + Accepts plain paths like `/catalog` or `/foo?bar=baz`. Rejects + `javascript:...`, `http://...`, `//evil/`, bare `dashboard`, empty/None, etc. + """ + if not candidate or not isinstance(candidate, str): + return default + if not candidate.startswith("/"): + return default + if candidate.startswith("//"): + return default + return candidate diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 22101fc..421c7ff 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -19,6 +19,26 @@ def _get_db(): conn.close() +def _client_ip(request: Optional[Request]) -> Optional[str]: + """Return the request's client IP, preferring the first hop of X-Forwarded-For. + + Trust model: this deployment runs behind Caddy (see repo Caddyfile), which + strips incoming X-Forwarded-For and sets its own. The leftmost hop is + therefore trustworthy. If the app is ever exposed directly to the internet + without a proxy, this value becomes client-settable and should only be + relied on for audit/diagnostics, never access control. Value is stored in + personal_access_tokens.last_used_ip and audit_log entries — informational + only, never authorization. + """ + if request is None: + return None + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",", 1)[0].strip() or None + client = getattr(request, "client", None) + return getattr(client, "host", None) if client else None + + async def get_current_user( request: Request = None, authorization: Optional[str] = Header(None), @@ -54,6 +74,69 @@ async def get_current_user( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", ) + if not bool(user.get("active", True)): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Account deactivated", + ) + + # PAT validation: check it's not revoked / expired / unknown in DB. + if payload.get("typ") == "pat": + from datetime import datetime, timezone + import hashlib + from src.repositories.access_tokens import AccessTokenRepository + + def _fail(detail: str) -> None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=detail + ) + + tokens_repo = AccessTokenRepository(conn) + record = tokens_repo.get_by_id(payload.get("jti", "")) + if not record: + _fail("Token unknown") + if record.get("revoked_at") is not None: + _fail("Token revoked") + exp_at = record.get("expires_at") + if exp_at is not None: + if isinstance(exp_at, str): + exp_at = datetime.fromisoformat(exp_at) + if exp_at.tzinfo is None: + exp_at = exp_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) > exp_at: + _fail("Token expired") + # Defense-in-depth: stored token_hash must match sha256(bearer JWT). + # Protects against a forged-but-unrevoked JWT using a stolen key. + stored_hash = record.get("token_hash") + if stored_hash: + actual = hashlib.sha256(token.encode()).hexdigest() + if actual != stored_hash: + _fail("Token mismatch") + + # First-use-from-new-IP audit entry (#12 acceptance criterion). + # Only emit when the IP changes on a *subsequent* use — the very + # first use of a token is not surprising and doesn't need an entry. + current_ip = _client_ip(request) + previous_ip = record.get("last_used_ip") + already_used = record.get("last_used_at") is not None + if already_used and current_ip and current_ip != previous_ip: + try: + from src.repositories.audit import AuditRepository + AuditRepository(conn).log( + user_id=user["id"], + action="token.first_use_new_ip", + resource=f"token:{payload['jti']}", + params={"ip": current_ip, "previous_ip": previous_ip}, + ) + except Exception: + pass # audit failure must not block auth + + # Record last_used_at / last_used_ip synchronously — acceptable cost; can batch later. + try: + tokens_repo.mark_used(payload["jti"], ip=current_ip) + except Exception: + pass + return user @@ -90,3 +173,23 @@ async def require_admin(user: dict = Depends(get_current_user)) -> dict: detail="Admin access required", ) return user + + +async def require_session_token(request: Request, user: dict = Depends(get_current_user)) -> dict: + """Like get_current_user but rejects PAT — for endpoints that must not + be callable via a long-lived CI token (e.g. creating new tokens, changing password).""" + auth = request.headers.get("authorization", "") + token = None + if auth.startswith("Bearer "): + token = auth.removeprefix("Bearer ") + if not token and request: + token = request.cookies.get("access_token") + if token: + from app.auth.jwt import verify_token + payload = verify_token(token) or {} + if payload.get("typ") == "pat": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This endpoint requires an interactive session, not a PAT", + ) + return user diff --git a/app/auth/jwt.py b/app/auth/jwt.py index 2816c64..c6ac969 100644 --- a/app/auth/jwt.py +++ b/app/auth/jwt.py @@ -48,18 +48,30 @@ def create_access_token( email: str, role: str = "analyst", expires_delta: Optional[timedelta] = None, + token_id: Optional[str] = None, + typ: str = "session", + omit_exp: bool = False, ) -> str: - expire = datetime.now(timezone.utc) + ( - expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) - ) + """Create a JWT. `typ` is "session" (interactive login) or "pat" (long-lived). + + If `omit_exp=True`, no `exp` claim is embedded. This is used by PATs with + "no expiry" — the authoritative expiry check is the DB row in + `personal_access_tokens.expires_at`, and a claim-less JWT avoids the + misleading ~100y horizon that previously pretended to be "never". + """ payload = { "sub": user_id, "email": email, "role": role, - "exp": expire, + "typ": typ, "iat": datetime.now(timezone.utc), - "jti": uuid.uuid4().hex, + "jti": token_id or uuid.uuid4().hex, } + if not omit_exp: + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + ) + payload["exp"] = expire return jwt.encode(payload, _get_cached_secret_key(), algorithm=ALGORITHM) diff --git a/app/auth/providers/google.py b/app/auth/providers/google.py index 45e019d..9d5b86e 100644 --- a/app/auth/providers/google.py +++ b/app/auth/providers/google.py @@ -9,6 +9,7 @@ from fastapi.responses import RedirectResponse from starlette.config import Config as StarletteConfig from app.auth.jwt import create_access_token +from app.auth._common import safe_next_path from app.instance_config import get_allowed_domains logger = logging.getLogger(__name__) @@ -42,9 +43,21 @@ _setup_oauth() @router.get("/login") async def google_login(request: Request): - """Redirect to Google OAuth.""" + """Redirect to Google OAuth. + + Honors `?next=` by stashing the sanitized value in the session so the + callback can redirect there instead of the default /dashboard. The session + is the right stash — OAuth flow is stateful and the `state` param is + managed by Authlib. + """ if not is_available(): return RedirectResponse(url="/login?error=google_not_configured") + next_path = safe_next_path(request.query_params.get("next"), default="") + if next_path: + request.session["login_next"] = next_path + else: + # Clear any stale value from an earlier aborted attempt. + request.session.pop("login_next", None) redirect_uri = str(request.url_for("google_callback")) return await oauth.google.authorize_redirect(request, redirect_uri) @@ -84,15 +97,23 @@ async def google_callback(request: Request): user_id = str(uuid.uuid4()) repo.create(id=user_id, email=email, name=name, role="analyst") user = repo.get_by_email(email) + if not bool(user.get("active", True)): + return RedirectResponse(url="/login?error=deactivated") finally: conn.close() # Issue JWT jwt_token = create_access_token(user["id"], user["email"], user["role"]) - # Redirect to dashboard with token in cookie + # Redirect to the post-login target. Prefer the value stashed by + # google_login() — re-sanitize defensively in case of session tampering. + target = safe_next_path( + request.session.pop("login_next", None), default="/dashboard" + ) + + # Redirect to target with token in cookie is_production = os.environ.get("TESTING", "").lower() not in ("1", "true") - response = RedirectResponse(url="/dashboard", status_code=302) + response = RedirectResponse(url=target, status_code=302) response.set_cookie( key="access_token", value=jwt_token, httponly=True, max_age=86400, samesite="lax", diff --git a/app/auth/providers/password.py b/app/auth/providers/password.py index 4437055..8a976e8 100644 --- a/app/auth/providers/password.py +++ b/app/auth/providers/password.py @@ -43,6 +43,8 @@ async def password_login( user = repo.get_by_email(request.email) if not user or not user.get("password_hash"): raise HTTPException(status_code=401, detail="Invalid email or password") + if not bool(user.get("active", True)): + raise HTTPException(status_code=401, detail="Account deactivated") # Verify password try: @@ -62,25 +64,37 @@ async def password_login( async def password_login_web( email: str = Form(...), password: str = Form(""), + next: str = Form(""), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): - """Web form login — sets cookie and redirects to dashboard.""" + """Web form login — sets cookie and redirects to `next` (or /dashboard).""" repo = UserRepository(conn) user = repo.get_by_email(email) if not user or not user.get("password_hash"): return RedirectResponse(url="/login/password?error=invalid", status_code=302) + if not bool(user.get("active", True)): + return RedirectResponse(url="/login/password?error=deactivated", status_code=302) try: ph = PasswordHasher() ph.verify(user["password_hash"], password) - except (VerifyMismatchError, Exception): + except VerifyMismatchError: + # Genuinely wrong password → usual UX. return RedirectResponse(url="/login/password?error=invalid", status_code=302) + except Exception: + # Corrupted hash / library error → surface a distinct error code so ops + # can tell broken-hash cases apart from bad-password cases. Log loudly. + logger.exception("Unexpected error during web password verification for %s", email) + return RedirectResponse(url="/login/password?err=auth_internal", status_code=302) token = create_access_token(user["id"], user["email"], user["role"]) # Secure cookie only over HTTPS (detect via X-Forwarded-Proto or request scheme) # For dev/staging on plain HTTP, secure=False so the cookie is actually sent use_secure = os.environ.get("DOMAIN", "") != "" # DOMAIN set = production with TLS - response = RedirectResponse(url="/dashboard", status_code=302) + + # Sanitize `next`: must start with `/` and must not start with `//` (open-redirect guard) + target = next if (next.startswith("/") and not next.startswith("//")) else "/dashboard" + response = RedirectResponse(url=target, status_code=302) response.set_cookie( key="access_token", value=token, httponly=True, max_age=86400, samesite="lax", diff --git a/app/auth/router.py b/app/auth/router.py index 99a76ea..8fac00b 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -65,6 +65,9 @@ async def create_token( user = repo.get_by_email(request.email) if not user: raise HTTPException(status_code=401, detail="User not found") + if not bool(user.get("active", True)): + _audit(user["id"], "login_failed", result="deactivated") + raise HTTPException(status_code=401, detail="Account deactivated") # If user has password_hash, require and verify it if user.get("password_hash"): diff --git a/app/main.py b/app/main.py index 1753343..dd92196 100644 --- a/app/main.py +++ b/app/main.py @@ -3,12 +3,15 @@ import logging from contextlib import asynccontextmanager from pathlib import Path +from urllib.parse import quote import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware from app.auth.router import router as auth_router @@ -30,6 +33,8 @@ from app.api.jira_webhooks import router as jira_webhooks_router from app.api.metrics import router as metrics_router from app.api.metadata import router as metadata_router from app.api.query_hybrid import router as query_hybrid_router +from app.api.cli_artifacts import router as cli_artifacts_router +from app.api.tokens import router as tokens_router, admin_router as tokens_admin_router from app.web.router import router as web_router logger = logging.getLogger(__name__) @@ -157,10 +162,33 @@ def create_app() -> FastAPI: app.include_router(metrics_router) app.include_router(metadata_router) app.include_router(query_hybrid_router) + app.include_router(cli_artifacts_router) + app.include_router(tokens_router) + app.include_router(tokens_admin_router) # Web UI router (must be last — has catch-all routes) app.include_router(web_router) + @app.exception_handler(StarletteHTTPException) + async def _html_auth_redirect_handler(request, exc: StarletteHTTPException): + """Redirect unauthenticated HTML page loads (GET) to /login. + + Only GET requests outside `/api/` and `/auth/` are redirected — that + targets browser navigations to HTML pages. POSTs, API prefixes, and + non-401 errors fall through to Starlette's default JSON response so + JSON clients (including `/auth/tokens` for PAT CRUD) keep their + existing contract. + """ + if ( + exc.status_code == 401 + and request.method == "GET" + and not request.url.path.startswith(("/api/", "/auth/")) + ): + next_param = quote(request.url.path, safe="") + return RedirectResponse(url=f"/login?next={next_param}", status_code=302) + from fastapi.exception_handlers import http_exception_handler + return await http_exception_handler(request, exc) + return app diff --git a/app/web/router.py b/app/web/router.py index 08f4e7c..84d0e0a 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -8,6 +8,7 @@ import os from datetime import datetime from pathlib import Path from typing import Optional +from urllib.parse import quote from fastapi import APIRouter, Depends, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse @@ -152,6 +153,11 @@ def _build_context(request: Request, user: Optional[dict] = None, **extra) -> di return {k: v for k, v in theme.items() if v} return {} + # Lines + server_url for the "Setup a new Claude Code" preview/clipboard + # partial; single source of truth lives in app/web/setup_instructions.py. + from app.web.setup_instructions import SETUP_INSTRUCTIONS_LINES + ctx_server_url = str(request.base_url).rstrip("/") + ctx = { "request": request, "config": ConfigProxy, @@ -162,8 +168,11 @@ def _build_context(request: Request, user: Optional[dict] = None, **extra) -> di "get_flashed_messages": lambda **kwargs: [], "url_for": lambda endpoint, **kw: _url_for_shim(endpoint, **kw), "session": _FlexDict({"user": user}) if user else _FlexDict(), + "setup_instructions_lines": SETUP_INSTRUCTIONS_LINES, + "server_url": ctx_server_url, } # Flex all extra context values for template compatibility + # (but skip ones we just populated — extras with the same key win) for k, v in extra.items(): ctx[k] = _flex(v) if isinstance(v, (dict, list)) else v return ctx @@ -192,6 +201,10 @@ async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depen @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): + next_path = request.query_params.get("next", "") + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "" + providers = [] try: from app.auth.providers.google import is_available as google_available @@ -211,39 +224,54 @@ async def login_page(request: Request): login_buttons = [] for p in providers: if p["name"] == "google": - login_buttons.append({"url": "/auth/google/login", "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""}) + _url = "/auth/google/login" + if next_path: + _url += f"?next={quote(next_path, safe='')}" + login_buttons.append({"url": _url, "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""}) elif p["name"] == "password": - login_buttons.append({"url": "/login/password", "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""}) + _url = "/login/password" + if next_path: + _url += f"?next={quote(next_path, safe='')}" + login_buttons.append({"url": _url, "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""}) elif p["name"] == "email": - login_buttons.append({"url": "/login/email", "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""}) + _url = "/login/email" + if next_path: + _url += f"?next={quote(next_path, safe='')}" + login_buttons.append({"url": _url, "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""}) - ctx = _build_context(request, providers=providers, login_buttons=login_buttons) + ctx = _build_context(request, providers=providers, login_buttons=login_buttons, next_path=next_path) return templates.TemplateResponse(request, "login.html", ctx) @router.get("/login/password", response_class=HTMLResponse) async def login_password_page(request: Request): """Password login form (email + password).""" + next_path = request.query_params.get("next", "") + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass - ctx = _build_context(request, google_available=google_ok) + ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) @router.get("/login/email", response_class=HTMLResponse) async def login_email_page(request: Request): """Email magic link login form.""" + next_path = request.query_params.get("next", "") + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "" google_ok = False try: from app.auth.providers.google import is_available as google_available google_ok = google_available() except Exception: pass - ctx = _build_context(request, google_available=google_ok) + ctx = _build_context(request, google_available=google_ok, next_path=next_path) return templates.TemplateResponse(request, "login_email.html", ctx) @@ -288,7 +316,6 @@ async def dashboard( account_status="active", account_details=None, telegram_status={"linked": False}, - setup_instructions="Use 'da login' to connect your CLI tool.", data_stats={ "tables": total_tables, "total_tables": total_tables, @@ -495,6 +522,22 @@ async def activity_center( return templates.TemplateResponse(request, "activity_center.html", ctx) +@router.get("/install", response_class=HTMLResponse) +async def install_page( + request: Request, + user: Optional[dict] = Depends(get_optional_user), +): + """Public install instructions for the CLI.""" + base_url = str(request.base_url).rstrip("/") + ctx = _build_context( + request, + user=user, + server_url=base_url, + agnes_version=os.environ.get("AGNES_VERSION", "dev"), + ) + return templates.TemplateResponse(request, "install.html", ctx) + + @router.get("/admin/tables", response_class=HTMLResponse) async def admin_tables( request: Request, @@ -517,3 +560,48 @@ async def admin_permissions_page( """Admin page for managing permissions and access requests.""" ctx = _build_context(request, user=user) return templates.TemplateResponse(request, "admin_permissions.html", ctx) + + +@router.get("/admin/users", response_class=HTMLResponse) +async def admin_users_page( + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), +): + """Admin page for user management.""" + ctx = _build_context(request, user=user) + return templates.TemplateResponse(request, "admin_users.html", ctx) + + +@router.get("/tokens", response_class=HTMLResponse) +async def my_tokens_page( + request: Request, + user: dict = Depends(get_current_user), +): + """My tokens — ANY signed-in user (incl. admins' own). + + Always shows the user's own PATs. Create + reveal + revoke-own flow. + Admins who need the org-wide view go to /admin/tokens. + """ + ctx = _build_context(request, user=user) + return templates.TemplateResponse(request, "my_tokens.html", ctx) + + +@router.get("/admin/tokens", response_class=HTMLResponse) +async def admin_tokens_page( + request: Request, + user: dict = Depends(require_role(Role.ADMIN)), +): + """Admin — list of ALL tokens for incident response + offboarding. + + Admin-only. No create form here (admins mint their own PATs via /tokens). + URL param ?user= pre-fills the owner filter (deep-link from + /admin/users "Tokens" action). + """ + ctx = _build_context(request, user=user) + return templates.TemplateResponse(request, "admin_tokens.html", ctx) + + +@router.get("/profile") +async def profile_redirect(request: Request): + """Back-compat: /profile (PAT CRUD) has been unified under /tokens.""" + return RedirectResponse(url="/tokens", status_code=302) diff --git a/app/web/setup_instructions.py b/app/web/setup_instructions.py new file mode 100644 index 0000000..bedb9e0 --- /dev/null +++ b/app/web/setup_instructions.py @@ -0,0 +1,79 @@ +"""Single source of truth for the "Setup a new Claude Code" clipboard payload. + +Both the JS-embedded clipboard renderer (`_claude_setup_instructions.jinja`) +and the read-only HTML preview on the dashboard and /install pages consume +these lines. Keep it in Python so there is exactly ONE place that edits. + +Placeholders `{server_url}` and `{token}` are substituted at render time. +For the preview we substitute `{token}` with a user-visible placeholder +string styled distinctly in the HTML preview. +""" + +from __future__ import annotations + +SETUP_INSTRUCTIONS_LINES: list[str] = [ + "Set up the Agnes CLI on this machine.", + "", + "Server: {server_url}", + "Personal access token: {token}", + "(Just generated; treat it as a secret.)", + "", + "Run these, in order. If any step fails, paste the exact error back and stop.", + "", + "1) Install the CLI:", + " uv tool install --force {server_url}/cli/agnes.whl", + "", + " If uv is not installed yet:", + " curl -LsSf https://astral.sh/uv/install.sh | sh", + "", + " If `da --version` fails after install because ~/.local/bin is not on PATH:", + " export PATH=\"$HOME/.local/bin:$PATH\"", + " # persist: append the same line to your ~/.zshrc or ~/.bashrc", + "", + "2) Log in (also saves the server URL):", + " da auth import-token --token \"{token}\" --server \"{server_url}\"", + "", + "3) Verify the login:", + " da auth whoami", + "", + "4) Run diagnostics:", + " da diagnose", + "", + " This should print \"Overall: healthy\" and a list of green checks. If", + " anything is yellow/red, paste the full output back.", + "", + "5) Skills (ask the user first):", + " The CLI ships with reusable markdown skills (setup, connectors,", + " corporate-memory, deploy, notifications, security, troubleshoot),", + " listable via `da skills list` and readable via `da skills show `.", + "", + " Ask the user verbatim: \"Do you want me to copy the Agnes skills into", + " ~/.claude/skills/agnes/ so they are always loaded in Claude Code,", + " or should I pull them on-demand via `da skills show ` when", + " needed?\"", + "", + " If they say copy:", + " mkdir -p ~/.claude/skills/agnes", + " for s in $(da skills list | awk '{print $1}'); do", + " da skills show \"$s\" > ~/.claude/skills/agnes/\"$s\".md", + " done", + " echo \"Copied skills to ~/.claude/skills/agnes/\"", + "", + "6) Confirm:", + " Tell me \"Agnes CLI is ready\" and summarize:", + " - `da --version` output", + " - `da auth whoami` output (email + role)", + " - Whether skills were copied or left on-demand", + " - The `da diagnose` overall status", +] + + +def render_setup_instructions(server_url: str, token: str) -> str: + """Render the setup instructions as a single string. + + Used server-side for tests and any non-JS rendering path. The browser + clipboard flow uses the JS renderer embedded in the Jinja partial; both + must produce byte-identical output for a given (server_url, token). + """ + text = "\n".join(SETUP_INSTRUCTIONS_LINES) + return text.replace("{server_url}", server_url).replace("{token}", token) diff --git a/app/web/static/style-custom.css b/app/web/static/style-custom.css index ce09fab..c9f4824 100644 --- a/app/web/static/style-custom.css +++ b/app/web/static/style-custom.css @@ -2040,3 +2040,164 @@ a.slack-badge:hover { background: #d97706; transform: translateY(-1px); } + +/* ─── Shared modern header (used by base.html + future pages) ─── */ +/* Mirrors the inline header styles in dashboard.html so all pages share chrome. */ + +.app-header { + background: var(--surface, #fff); + border-bottom: 1px solid var(--border, #e5e7eb); + padding: 0 32px; + height: 72px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.app-header-left { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +} + +.app-header-logo { + display: inline-flex; + align-items: center; + text-decoration: none; + color: inherit; + font-weight: 600; + font-size: 16px; +} +.app-header-logo svg { display: block; } +a.app-header-logo:focus-visible { + outline: 2px solid var(--primary, #6366f1); + outline-offset: 2px; + border-radius: 4px; +} + +.app-header-subtitle { + font-size: 11px; + font-weight: 500; + color: var(--text-secondary, #6b7280); + letter-spacing: 0.4px; + text-transform: uppercase; + margin-top: 2px; +} + +.app-header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.app-header-email { + font-size: 13px; + color: var(--text-secondary, #6b7280); + font-weight: 500; +} + +.app-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--primary-light, #eef2ff); + color: var(--primary, #6366f1); + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + letter-spacing: 0.3px; +} + +.app-avatar-img { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid var(--border, #e5e7eb); +} + +.app-nav-link { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary, #6b7280); + text-decoration: none; + padding: 6px 12px; + border-radius: 8px; + transition: all 0.15s ease; +} +.app-nav-link:hover { + color: var(--text-primary, #111827); + background: var(--border-light, #f3f4f6); +} +.app-nav-link.is-active { + color: var(--primary, #6366f1); + background: var(--primary-light, #eef2ff); +} + +.app-btn-logout { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary, #6b7280); + background: none; + border: 1px solid var(--border, #e5e7eb); + border-radius: 8px; + padding: 6px 14px; + cursor: pointer; + transition: all 0.15s ease; + text-decoration: none; + display: inline-block; +} +.app-btn-logout:hover { + color: var(--text-primary, #111827); + border-color: #d1d5db; + background: var(--border-light, #f3f4f6); +} + +/* ── User menu (dropdown) ── */ +.app-user-menu { position: relative; display: inline-flex; align-items: center; } +.app-user-menu-trigger { + display: inline-flex; align-items: center; gap: 6px; + background: none; border: 1px solid transparent; + border-radius: 999px; padding: 4px 10px 4px 4px; + cursor: pointer; transition: all 0.15s ease; +} +.app-user-menu-trigger:hover { background: var(--border-light, #f3f4f6); border-color: var(--border, #e5e7eb); } +.app-user-menu-trigger[aria-expanded="true"] { background: var(--border-light, #f3f4f6); border-color: var(--border, #e5e7eb); } +.app-user-menu-chevron { color: var(--text-secondary, #6b7280); transition: transform 0.15s ease; } +.app-user-menu-trigger[aria-expanded="true"] .app-user-menu-chevron { transform: rotate(180deg); } +.app-user-menu-panel { + position: absolute; top: calc(100% + 8px); right: 0; + min-width: 220px; + background: var(--surface, #fff); + border: 1px solid var(--border, #e5e7eb); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + padding: 6px; + z-index: 50; +} +.app-user-menu-panel[hidden] { display: none; } +.app-user-menu-header { + padding: 10px 12px 8px; + border-bottom: 1px solid var(--border-light, #f3f4f6); + margin-bottom: 4px; +} +.app-user-menu-email { font-size: 13px; font-weight: 500; color: var(--text-primary, #111827); word-break: break-all; } +.app-user-menu-role { font-size: 11px; color: var(--text-secondary, #6b7280); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.3px; } +.app-user-menu-item { + display: block; padding: 8px 12px; + font-size: 13px; color: var(--text-primary, #111827); + text-decoration: none; border-radius: 6px; +} +.app-user-menu-item:hover { background: var(--border-light, #f3f4f6); } +.app-user-menu-item.is-active { background: rgba(0, 115, 209, 0.08); color: var(--primary, #0073D1); font-weight: 500; } + +@media (max-width: 720px) { + .app-header { padding: 0 16px; gap: 8px; } + .app-header-email { display: none; } + .app-nav-link { padding: 6px 8px; } +} diff --git a/app/web/static/style.css b/app/web/static/style.css index c232611..8d4449d 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -54,6 +54,9 @@ header { gap: 16px; } +a.logo { text-decoration: none; color: inherit; display: block; } +a.logo:hover h1 { color: var(--primary); } + .logo h1 { font-size: 1.375rem; font-weight: 600; diff --git a/app/web/templates/_app_header.html b/app/web/templates/_app_header.html new file mode 100644 index 0000000..1d292ca --- /dev/null +++ b/app/web/templates/_app_header.html @@ -0,0 +1,67 @@ +{# Shared modern header — used by base.html and dashboard.html. + Styles live in app/web/static/style-custom.css under the .app-* prefix. #} +{% if session.user %} +
    + +
    + {% set _path = request.url.path %} + Dashboard + Install CLI + {% if session.user.role == 'admin' %} + Users + All tokens + {% endif %} + +
    + + +
    +
    +
    + +{% endif %} diff --git a/app/web/templates/_claude_setup_instructions.jinja b/app/web/templates/_claude_setup_instructions.jinja new file mode 100644 index 0000000..30d0431 --- /dev/null +++ b/app/web/templates/_claude_setup_instructions.jinja @@ -0,0 +1,44 @@ +{# Single source of truth for the "Setup a new Claude Code" clipboard payload. + + Two modes: + + * preview_mode=True → emits a read-only HTML
     block rendered
    +                           with the real server_url and a visible placeholder
    +                           for the token. Used inline on /dashboard and
    +                           /install so the reader can see exactly what will
    +                           land in their clipboard.
    +   * preview_mode=False →  emits the JS `SETUP_INSTRUCTIONS_TEMPLATE` array +
    +                           `renderSetupInstructions(server, token)` function.
    +                           Placed inside a 
    +{% endblock %}
    diff --git a/app/web/templates/admin_users.html b/app/web/templates/admin_users.html
    new file mode 100644
    index 0000000..3a92888
    --- /dev/null
    +++ b/app/web/templates/admin_users.html
    @@ -0,0 +1,553 @@
    +{% extends "base.html" %}
    +{% block title %}Users — {{ config.INSTANCE_NAME }}{% endblock %}
    +
    +{% block content %}
    +
    +
    +
    +
    +

    Users

    + + +
    + +
    + + + + + + + + + + + + +
    UserRoleActiveCreatedDeactivatedActions
    +
    Loading users…
    + +
    +
    + + + + + + + + + + + + + +
    + + +{% endblock %} diff --git a/app/web/templates/base.html b/app/web/templates/base.html index f6962c9..068d4ca 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -9,25 +9,9 @@ {% include '_theme.html' %} -
    -
    - - {% if session.user %} - - {% endif %} -
    + {% include '_app_header.html' %} +
    {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
    diff --git a/app/web/templates/catalog.html b/app/web/templates/catalog.html index 894abac..0141360 100644 --- a/app/web/templates/catalog.html +++ b/app/web/templates/catalog.html @@ -1494,24 +1494,7 @@ -
    -
    - - - - - -
    - - Data Catalog -
    -
    -
    - {% if data_stats.last_updated %}Last sync: {{ data_stats.last_updated }}{% endif %} -
    -
    + {% include '_app_header.html' %}
    diff --git a/app/web/templates/corporate_memory.html b/app/web/templates/corporate_memory.html index f91cc33..b9a1851 100644 --- a/app/web/templates/corporate_memory.html +++ b/app/web/templates/corporate_memory.html @@ -514,49 +514,7 @@
    - + {% include '_app_header.html' %}
    diff --git a/app/web/templates/corporate_memory_admin.html b/app/web/templates/corporate_memory_admin.html index d8b0a28..e73235f 100644 --- a/app/web/templates/corporate_memory_admin.html +++ b/app/web/templates/corporate_memory_admin.html @@ -802,34 +802,7 @@
    - + {% include '_app_header.html' %}
    diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index bbb0190..8bb94f3 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -32,9 +32,20 @@ gap: 2px; } + .header-logo { + display: inline-flex; + align-items: center; + text-decoration: none; + color: inherit; + } .header-logo svg { display: block; } + a.header-logo:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + border-radius: 4px; + } .header-subtitle { font-size: 11px; @@ -1221,6 +1232,147 @@ margin-left: auto; } + /* Error banner for setup flow */ + .setup-error { + margin-top: 12px; + padding: 10px 14px; + background: rgba(234, 88, 12, 0.12); + border-left: 3px solid #EA580C; + border-radius: 6px; + color: #FFF; + font-size: 13px; + } + .env-setup-cta .btn-setup[disabled] { + opacity: 0.7; + cursor: wait; + } + + /* ── Setup instructions preview (read-only card inside env-setup-cta) ── */ + .setup-preview-card { + margin-top: 18px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 14px 16px; + } + .setup-preview-summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + user-select: none; + } + .setup-preview-summary::-webkit-details-marker { display: none; } + .setup-preview-chevron { + display: inline-block; + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + transition: transform 0.15s ease; + } + details[open] > .setup-preview-summary .setup-preview-chevron { transform: rotate(90deg); } + .setup-preview-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.85); + margin: 0; + } + .setup-preview-sub { + font-size: 12px; + color: rgba(255, 255, 255, 0.65); + margin: 0 0 10px 0; + } + .setup-preview-pre { + background: #1e1e2e; + border-radius: 6px; + padding: 14px 16px; + margin: 0; + max-height: 400px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.55; + color: #cdd6f4; + white-space: pre-wrap; + word-break: break-word; + } + .setup-preview-code { font-family: inherit; font-size: inherit; } + .setup-preview-pre .placeholder-token { + background: rgba(249, 226, 175, 0.12); + color: #f9e2af; + padding: 0 4px; + border-radius: 3px; + font-style: italic; + } + + /* Fallback modal (when clipboard is blocked) */ + .setup-fallback-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + .setup-fallback-modal { + background: var(--surface); + border-radius: 12px; + padding: 20px; + max-width: 720px; + width: calc(100% - 32px); + max-height: calc(100vh - 64px); + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.3); + } + .setup-fallback-modal h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } + .setup-fallback-modal p { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + } + .setup-fallback-modal textarea { + flex: 1; + min-height: 260px; + font-family: var(--font-mono); + font-size: 12px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--background); + color: var(--text-primary); + resize: vertical; + } + .setup-fallback-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + .setup-fallback-actions button { + font-family: var(--font-primary); + font-size: 13px; + font-weight: 500; + padding: 8px 16px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-primary); + cursor: pointer; + } + .setup-fallback-actions button.primary { + background: var(--primary); + color: #FFF; + border-color: var(--primary); + } + /* ── Setup Banner (bottom, for returning users) ── */ .setup-banner { background: var(--background); @@ -1834,26 +1986,8 @@ - -
    -
    - - Data Analyst Portal -
    - {% if session.user %} -
    - {{ session.user.email }} - {% if session.user.picture %} - Profile - {% else %} -
    {{ (user.name or user.email)[:2] | upper }}
    - {% endif %} - Logout -
    - {% endif %} -
    + + {% include '_app_header.html' %} {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -1875,19 +2009,33 @@ {% if not account_details or not account_details.last_sync_display %}
    -

    Set up your local environment

    -

    Run Claude Code in your project folder and paste the setup instructions to configure SSH, sync data, and initialize DuckDB.

    +

    Set up a new Claude Code

    +

    Generates a personal access token and copies a ready-to-paste setup script to your clipboard. Paste into Claude Code to finish.

    - cd {{ project_dir }} && claude - - Paste into Claude Code to complete setup + Valid 90 days · token stays in clipboard only
    + +
    + + + What Claude Code will receive + +

    + Read-only preview. The real token is generated the moment + you click the button above and is placed directly in your + clipboard — never shown on this page. +

    + {% with preview_mode=True %} + {% include "_claude_setup_instructions.jinja" %} + {% endwith %} +
    {% endif %} @@ -2266,17 +2414,6 @@
    - -
    -
    -
    Set up a new machine
    -
    Copy instructions and paste into Claude Code to configure another local environment.
    -
    - -
    - {% else %} @@ -2421,19 +2558,112 @@ }); } - function copyBootstrapInstructions(btn) { - var instructions = {{ setup_instructions | tojson }}; + // ══════════════════════════════════════════════════════════════════ + // "Setup a new Claude Code" one-click flow + // ══════════════════════════════════════════════════════════════════ + // Template + renderer included from _claude_setup_instructions.jinja + // so dashboard.html and install.html always render the same payload. + {% include "_claude_setup_instructions.jinja" %} - var button = btn || document.getElementById('bootstrapCopyBtn'); - var origText = button.textContent; - copyToClipboard(instructions).then(function() { - button.textContent = 'Copied!'; - button.classList.add('copied'); - setTimeout(function() { - button.textContent = origText; - button.classList.remove('copied'); - }, 2000); + function defaultTokenName() { + var stamp = new Date().toISOString().slice(0, 16).replace("T", " "); + return "Claude Code — " + stamp; + } + + function showSetupFallback(instructions) { + // Clipboard blocked (non-secure context, permission denied, etc.). + // Show a modal with the instructions preselected so the user can Ctrl+C. + var overlay = document.createElement('div'); + overlay.className = 'setup-fallback-overlay'; + overlay.innerHTML = + ''; + document.body.appendChild(overlay); + var ta = overlay.querySelector('textarea'); + ta.value = instructions; + ta.focus(); + ta.select(); + overlay.addEventListener('click', function(ev) { + if (ev.target === overlay) { document.body.removeChild(overlay); } }); + overlay.querySelector('[data-action="close"]').addEventListener('click', function() { + document.body.removeChild(overlay); + }); + overlay.querySelector('[data-action="select"]').addEventListener('click', function() { + ta.focus(); + ta.select(); + }); + } + + async function setupNewClaude(btn) { + var errEl = document.getElementById('setupClaudeError'); + if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } + var origText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Generating token…'; + try { + var resp = await fetch('/auth/tokens', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: defaultTokenName(), + expires_in_days: 90, + }), + }); + if (resp.status === 401) { + // Session expired mid-flight — bounce to login and come back. + window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname); + return; + } + if (!resp.ok) { + var detail = 'HTTP ' + resp.status; + try { + var body = await resp.json(); + if (body && body.detail) { detail = body.detail; } + } catch (_) { /* non-JSON */ } + throw new Error(detail); + } + var data = await resp.json(); + if (!data || !data.token) { + throw new Error('Server did not return a token.'); + } + var serverUrl = window.location.origin; + var instructions = renderSetupInstructions(serverUrl, data.token); + + try { + await copyToClipboard(instructions); + btn.textContent = 'Copied! Paste into Claude Code'; + btn.classList.add('copied'); + setTimeout(function() { + btn.textContent = origText; + btn.classList.remove('copied'); + btn.disabled = false; + }, 3000); + } catch (clipErr) { + // Clipboard denied — fall back to modal. Re-enable the button immediately. + btn.textContent = origText; + btn.disabled = false; + showSetupFallback(instructions); + } + // Token is NOT stored in DOM after the modal closes / flash disappears. + } catch (err) { + btn.textContent = origText; + btn.disabled = false; + if (errEl) { + errEl.textContent = 'Setup failed: ' + (err && err.message ? err.message : err); + errEl.style.display = 'block'; + } else { + alert('Setup failed: ' + (err && err.message ? err.message : err)); + } + } } async function updateSyncSettings() { diff --git a/app/web/templates/install.html b/app/web/templates/install.html new file mode 100644 index 0000000..9d37fc3 --- /dev/null +++ b/app/web/templates/install.html @@ -0,0 +1,1077 @@ + + + + + + Install CLI — {{ config.INSTANCE_NAME }} + {% if not config.THEME_FONT_URL %} + + + + {% endif %} + + + {% include '_theme.html' %} + + + + + {% include '_app_header.html' %} + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
    {{ message }}
    + {% endfor %} +
    + {% endif %} + {% endwith %} + +
    + + +
    +
    Getting started
    +

    Install the Agnes CLI on this machine

    +

    + Connect your terminal and Claude Code to this server. Copy the + one-liner below — it downloads and installs the CLI wheel, + then seeds your local config. +

    +
    + {{ server_url }} + {% if agnes_version and agnes_version != "dev" %} + v{{ agnes_version }} + {% endif %} +
    +
    + + +
    +
    + 1 + Quick install +
    +
    + {% if session.user %} +

    + One click generates a personal access token, assembles a + complete setup script (install CLI, save token, verify), + and copies it to your clipboard. Paste the result into + Claude Code to finish. +

    + +

    + Valid 90 days · token stays in your clipboard only. +

    + + +
    + + + What Claude Code will receive + +

    + Read-only preview. The real token is generated when you + click the button above and is placed directly in your + clipboard — it is never rendered on this page. +

    + {% with preview_mode=True %} + {% include "_claude_setup_instructions.jinja" %} + {% endwith %} +
    + +
    + Or run manually on a restricted environment +
    + $ + curl -fsSL {{ server_url }}/cli/install.sh | bash + +
    +

    + If da is not found in a new shell, add + ~/.local/bin to your PATH: +

    +
    + $ + export PATH="$HOME/.local/bin:$PATH" + +
    +

    + Add that line to your ~/.bashrc or + ~/.zshrc for persistence. +

    +
    + {% else %} +

    + Run this in your terminal (Linux / macOS). The installer + downloads the wheel and seeds ~/.config/da/config.yaml + with this server URL. +

    +

    + Sign in to skip the manual steps — you'll get a one-click + setup with a pre-configured token. +

    +
    + $ + curl -fsSL {{ server_url }}/cli/install.sh | bash + +
    +

    + If da is not found in a new shell, add + ~/.local/bin to your PATH: +

    +
    + $ + export PATH="$HOME/.local/bin:$PATH" + +
    +

    + Add that line to your ~/.bashrc or + ~/.zshrc for persistence. +

    + {% endif %} +
    +
    + + + {% if not session.user %} +
    + + You'll need to sign in first to create a personal access token. + + Sign in → +
    + {% endif %} +
    +
    + 2 + Create a personal access token +
    +
    +

    + Tokens let the CLI, CI jobs, and Claude Code talk to the + server without a browser session. +

    + + Open /tokens + + + + + +

    + Export it for the current shell and verify the connection: +

    +
    + $ + export DA_TOKEN=<your-token> + +
    +
    + $ + da auth whoami + +
    +
    +
    + + +
    + + + Manual install + + For restricted environments, offline machines, or + Windows — download the wheel yourself. + + + + +
    +
      +
    1. + Download the wheel from + {{ server_url }}/cli/download. +
    2. +
    3. + Install it: +
      + $ + uv tool install ./agnes_the_ai_analyst-*.whl + +
      + — or — +
      + $ + python3 -m pip install --user ./agnes_the_ai_analyst-*.whl + +
      +

      + On macOS (Homebrew) or recent Debian/Ubuntu, + pip install --user is blocked by + PEP 668 — + prefer uv tool install above. The + pip command is for users with an + activated virtualenv. +

      +

      + If da is not found after install, ensure + ~/.local/bin is on your PATH: +

      +
      + $ + export PATH="$HOME/.local/bin:$PATH" + +
      +

      + Add that line to your ~/.bashrc or + ~/.zshrc to make it persistent. +

      +
    4. +
    5. + Seed the server URL: +
      + $ + mkdir -p ~/.config/da && echo "server: {{ server_url }}" > ~/.config/da/config.yaml + +
      +
    6. +
    7. + Continue with Step 2 — Create a personal + access token above. +
    8. +
    +
    +
    + + + + +
    + +
    +

    © {{ now().year if now is defined else 2024 }} {{ config.INSTANCE_COPYRIGHT or 'AI Data Analyst' }}

    +
    + + + {% include "_version_badge.html" %} + + diff --git a/app/web/templates/login_email.html b/app/web/templates/login_email.html index 3478b96..3c7ae29 100644 --- a/app/web/templates/login_email.html +++ b/app/web/templates/login_email.html @@ -19,6 +19,7 @@