diff --git a/.env.deploy.example b/.env.deploy.example new file mode 100644 index 0000000..043f0e2 --- /dev/null +++ b/.env.deploy.example @@ -0,0 +1,84 @@ +# Deployment template — copy to .env.deploy and `source` (or `set -a`-export) +# before running `./bin/up.sh --with-proxy`. +# +# The UI lives behind the bundled nginx proxy on port 80 (PROXY_HOST_PORT). +# Backend services are reached on whatever URL is registered for them: +# +# UI → http://:80 (this nginx ↔ web:3000) +# user-mgmt → https://usermanagement.brainkb.org (own domain) +# ml service → http://:8007 (host-mapped port) +# query service → http://:8010 +# chat service → http://:8011 (optional) +# +# As more services get their own public domains, swap their entries the +# same way `usermanagement.brainkb.org` was swapped — replace +# `http://:` with `https://.brainkb.org`. For +# same-system / single-host testing without TLS, you can revert any +# `https://.brainkb.org` back to `http://localhost:`. + +# ── Public origin ───────────────────────────────────────────────────────── +# Where users hit the UI. Used by NextAuth for OAuth callback URLs; +# must match the OAuth redirect URIs you registered with each provider. +NEXTAUTH_URL=http://localhost +NEXTAUTH_SECRET=replace-with-a-long-random-string + +# ── Host port mapping ───────────────────────────────────────────────────── +# Port the host binds for the nginx proxy. Behind an ALB you'd typically +# leave this at 80 and let the ALB terminate TLS. +PROXY_HOST_PORT=80 +# Useful when running without the proxy overlay; nginx isn't in the picture +# so the UI is reached directly on this port. +WEB_HOST_PORT=3000 + +# ── User management (https://usermanagement.brainkb.org) ──────────────── +# Reached over its own public hostname (terminating TLS at the LB / nginx +# in front). The OAuth provider redirect URIs registered in GitHub / +# ORCID / Globus must match this host: +# https://usermanagement.brainkb.org/api/auth//callback +# Backend's USERMANAGEMENT_PUBLIC_BASE_URL must also be set to this URL. +NEXT_PUBLIC_USER_MANAGEMENT_API_BASE=https://usermanagement.brainkb.org +NEXT_PUBLIC_TOKEN_ENDPOINT_USER_MANAGEMENT_SERVICE=https://usermanagement.brainkb.org/api/token +NEXT_PUBLIC_CREATE_USER_PROFILE_ENDPOINT_USER_MANAGEMENT_SERVICE=https://usermanagement.brainkb.org/api/users/profile +NEXT_PUBLIC_GET_ENDPOINT_USER_PROFILE_USER_MANAGEMENT_SERVICE=https://usermanagement.brainkb.org/api/users/profile +NEXT_PUBLIC_UPDATE_ENDPOINT_USER_PROFILE_USER_MANAGEMENT_SERVICE=https://usermanagement.brainkb.org/api/users/profile +NEXT_PUBLIC_GET_ENDPOINT_USER_ACTIVITY_USER_MANAGEMENT_SERVICE=https://usermanagement.brainkb.org/api/users/activities +NEXT_PUBLIC_ENABLE_PAGE_ACCESS_GATE=true + +# ── ML service (default port 8007) — SynthScholar, NER, struct.resources ── +NEXT_PUBLIC_ML_SERVICE_API_BASE=http://localhost:8007 +NEXT_PUBLIC_TOKEN_ENDPOINT_ML_SERVICE=http://localhost:8007/api/token + +# WebSockets — switch to wss:// when fronted by TLS. +NEXT_PUBLIC_API_NER_ENDPOINT=ws://localhost:8007/api/ws/ner +NEXT_PUBLIC_API_PDF2REPROSCHEMA_ENDPOINT=ws://localhost:8007/api/ws/pdf2reproschema +NEXT_PUBLIC_API_ADMIN_EXTRACT_STRUCTURED_RESOURCE_ENDPOINT=ws://localhost:8007/api/ws/extract-resources + +NEXT_PUBLIC_NER_GET_ENDPOINT=http://localhost:8007/api/ner +NEXT_PUBLIC_NER_SAVE_ENDPOINT=http://localhost:8007/api/save/ner +NEXT_PUBLIC_API_ADMIN_SAVE_STRUCTURED_RESOURCE_ENDPOINT=http://localhost:8007/api/save/structured-resource +NEXT_PUBLIC_API_ADMIN_GET_STRUCTURED_RESOURCE_ENDPOINT=http://localhost:8007/api/structured-resource + +# ── Query service (default port 8010) — SPARQL + KG ingest ──────────────── +NEXT_PUBLIC_TOKEN_ENDPOINT_QUERY_SERVICE=http://localhost:8010/api/token +NEXT_PUBLIC_API_QUERY_ENDPOINT=http://localhost:8010/api/query/sparql/ +NEXT_PUBLIC_API_NAMED_GRAPH_QUERY_ENDPOINT=http://localhost:8010/api/query/registered-named-graphs +NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_ENDPOINT=http://localhost:8010/api/insert/files/knowledge-graph-triples +NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT=http://localhost:8010/api/insert/jobs +NEXT_PUBLIC_API_ADMIN_INSERT_ALL_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT=http://localhost:8010/api/insert/user/jobs/detail +NEXT_PUBLIC_API_ADMIN_INSERT_RECOVERY_JOB_ENDPOINT=http://localhost:8010/api/insert/jobs/recover +NEXT_PUBLIC_API_ADMIN_INSERT_CHECK_RECOVERABLE_JOB_ENDPOINT=http://localhost:8010/api/insert/jobs/check-recoverable + +# ── Chat service (default port 8011, optional) ──────────────────────────── +NEXT_PUBLIC_TOKEN_ENDPOINT_CHAT_SERVICE=http://localhost:8011/api/token +NEXT_PUBLIC_CHAT_SERVICE_API_ENDPOINT=http://localhost:8011/api/chat?stream=false + +# ── Service-account JWT (used by the UI to call backend APIs) ───────────── +# These are sent in `Authorization: Bearer …` from the UI to backend services +# and are NOT exposed to end-user browsers — but they ARE inlined by Next.js. +# Treat the deployment as if the values are public; rotate on the backend if +# leaked. +NEXT_PUBLIC_JWT_USER=service@example.com +NEXT_PUBLIC_JWT_PASSWORD=replace-with-a-strong-secret + +# ── Misc ────────────────────────────────────────────────────────────────── +NEXT_PUBLIC_IPIFY_KEY= diff --git a/.env.local b/.env.local index da2ecaf..a46ba77 100644 --- a/.env.local +++ b/.env.local @@ -1,14 +1,10 @@ ######################################################################################################## -######### OAuth Credentials (Optional) ############################################################### -# Required only if you want to enable login via GitHub or access the admin dashboard. -# ORCID login is not supported in local development. +######### NextAuth (this UI) ########################################################################## +# OAuth provider credentials (GitHub / ORCID / Globus client ID + secret) are +# NOT read by this UI. They live on the usermanagement_service backend's .env +# (see BrainKB/.env). The backend exposes /api/auth/providers, which this UI +# calls to render the sign-in buttons, and handles the full OAuth flow itself. ######################################################################################################## -GITHUB_CLIENT_ID=XXXXXX -GITHUB_CLIENT_SECRET=XXXXXX - -ORCID_CLIENT_ID=APP-XXXXXX -ORCID_CLIENT_SECRET=XXXX - NEXTAUTH_SECRET=ANY_RANDOM_STRING_SECRET NEXTAUTH_URL=http://localhost:3000 #FOR LOCAL DEPLOYMENT @@ -38,19 +34,36 @@ NEXT_PUBLIC_API_ADMIN_GET_STRUCTURED_RESOURCE_ENDPOINT=http://localhost:8007/api # Common JWT credentials for accessing backend services. ######################################################################################################## -NEXT_PUBLIC_JWT_USER=XXXXXX -NEXT_PUBLIC_JWT_PASSWORD=XXXXXX +NEXT_PUBLIC_JWT_USER=test@example.com +NEXT_PUBLIC_JWT_PASSWORD=testpassword1 ######################################################################################################## ######### User Profile Management ##################################################################### -# Endpoints for creating, fetching, and updating user profiles. +# All four endpoints live on the same usermanagement_service backend (:8004). +# The historical split across :8008/:8009 was vestigial from when these were +# separate microservices. Keep these in sync with NEXT_PUBLIC_USER_MANAGEMENT_API_BASE +# below. +######################################################################################################## + +NEXT_PUBLIC_TOKEN_ENDPOINT_USER_MANAGEMENT_SERVICE=http://localhost:8004/api/token +NEXT_PUBLIC_CREATE_USER_PROFILE_ENDPOINT_USER_MANAGEMENT_SERVICE=http://localhost:8004/api/users/profile +NEXT_PUBLIC_GET_ENDPOINT_USER_PROFILE_USER_MANAGEMENT_SERVICE=http://localhost:8004/api/users/profile +NEXT_PUBLIC_UPDATE_ENDPOINT_USER_PROFILE_USER_MANAGEMENT_SERVICE=http://localhost:8004/api/users/profile +NEXT_PUBLIC_GET_ENDPOINT_USER_ACTIVITY_USER_MANAGEMENT_SERVICE=http://localhost:8004/api/users/activities + +######################################################################################################## +######### User Management Backend (RBAC + admin dashboard) ############################################ +# Base URL for usermanagement_service. Powers /api/auth/providers (sign-in +# dropdown), /api/users/me, /api/admin/*, and /api/access/page/ +# RBAC checks. +# +# In the backend's compose / start scripts, USERMANAGEMENT_SERVICE_PORT +# defaults to 8004 (other services on the same stack: API token :8000, +# query :8010, ML :8007). When running non-docker make sure the service +# binds to the same port, e.g. `uvicorn core.main:app --reload --port 8004`. ######################################################################################################## -NEXT_PUBLIC_TOKEN_ENDPOINT_USER_MANAGEMENT_SERVICE=http://localhost:8008/api/token -NEXT_PUBLIC_CREATE_USER_PROFILE_ENDPOINT_USER_MANAGEMENT_SERVICE=http://localhost:8008/api/users/profile -NEXT_PUBLIC_GET_ENDPOINT_USER_PROFILE_USER_MANAGEMENT_SERVICE=http://localhost:8009/api/users/profile -NEXT_PUBLIC_UPDATE_ENDPOINT_USER_PROFILE_USER_MANAGEMENT_SERVICE=http://localhost:8008/api/users/profile -NEXT_PUBLIC_GET_ENDPOINT_USER_ACTIVITY_USER_MANAGEMENT_SERVICE=http://localhost:8008/api/users/activities +NEXT_PUBLIC_USER_MANAGEMENT_API_BASE=http://127.0.0.1:8004 ######################################################################################################## @@ -79,4 +92,15 @@ NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT=http://localhost NEXT_PUBLIC_API_ADMIN_INSERT_ALL_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT=http://localhost:8010/api/insert/user/jobs/detail #recovery job NEXT_PUBLIC_API_ADMIN_INSERT_RECOVERY_JOB_ENDPOINT=http://localhost:8010/api/insert/jobs/recover -NEXT_PUBLIC_API_ADMIN_INSERT_CHECK_RECOVERABLE_JOB_ENDPOINT=http://localhost:8010/api/insert/jobs/check-recoverable \ No newline at end of file +NEXT_PUBLIC_API_ADMIN_INSERT_CHECK_RECOVERABLE_JOB_ENDPOINT=http://localhost:8010/api/insert/jobs/check-recoverable + +#enable rbac (default true). Set to "false" ONLY for local dev to bypass +#per-page RBAC for authenticated users. Authentication is always required. +NEXT_PUBLIC_ENABLE_PAGE_ACCESS_GATE=true + +######################################################################################################## +######### SynthScholar (PRISMA literature review) ##################################################### +# Base URL for ml_service. The SynthScholar router lives under /api/synth-scholar +# and requires a Bearer token from /api/token (uses JWT_USER/JWT_PASSWORD above). +######################################################################################################## +NEXT_PUBLIC_ML_SERVICE_API_BASE=http://localhost:8007 \ No newline at end of file diff --git a/.github/workflows/deploy-github-pages.yml b/.github/workflows/deploy-github-pages.yml new file mode 100644 index 0000000..d9a7ddf --- /dev/null +++ b/.github/workflows/deploy-github-pages.yml @@ -0,0 +1,116 @@ +name: Deploy to GitHub Pages + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, closed] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: gh-pages-deploy + cancel-in-progress: false + +jobs: + build-deploy: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Compute base path and destination + id: ctx + run: | + REPO_NAME="${GITHUB_REPOSITORY#*/}" + OWNER="${GITHUB_REPOSITORY%/*}" + if [ "$REPO_NAME" = "${OWNER}.github.io" ]; then + ROOT_BASE="" + else + ROOT_BASE="/$REPO_NAME" + fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + PR_NUM="${{ github.event.pull_request.number }}" + echo "base=$ROOT_BASE/pr-$PR_NUM" >> "$GITHUB_OUTPUT" + echo "dest=pr-$PR_NUM" >> "$GITHUB_OUTPUT" + echo "preview=true" >> "$GITHUB_OUTPUT" + else + echo "base=$ROOT_BASE" >> "$GITHUB_OUTPUT" + echo "dest=." >> "$GITHUB_OUTPUT" + echo "preview=false" >> "$GITHUB_OUTPUT" + fi + + - run: npm ci + + - name: Build static export + env: + EXPORT: "true" + NEXT_PUBLIC_BASE_PATH: ${{ steps.ctx.outputs.base }} + run: npm run build + + - name: Disable Jekyll processing + run: touch out/.nojekyll + + - name: Deploy to gh-pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./out + destination_dir: ${{ steps.ctx.outputs.dest }} + keep_files: true + commit_message: "Deploy ${{ steps.ctx.outputs.dest }} from ${{ github.sha }}" + + - name: Comment preview URL on PR + if: steps.ctx.outputs.preview == 'true' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const prNum = context.payload.pull_request.number; + const host = (repo === `${owner}.github.io`) + ? `https://${owner}.github.io` + : `https://${owner}.github.io/${repo}`; + const url = `${host}/pr-${prNum}/`; + const marker = ''; + const body = `${marker}\n🔎 **Pages preview:** ${url}\n\n_Built from ${context.sha.slice(0, 7)} · updates on each push to this PR._`; + const { data: comments } = await github.rest.issues.listComments({ + owner, repo, issue_number: prNum, + }); + const existing = comments.find(c => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: prNum, body }); + } + + cleanup: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: gh-pages + fetch-depth: 0 + + - name: Remove PR preview directory + run: | + set -e + PR_DIR="pr-${{ github.event.pull_request.number }}" + if [ -d "$PR_DIR" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm -rf "$PR_DIR" + git commit -m "Cleanup preview for PR #${{ github.event.pull_request.number }}" + git push + else + echo "No preview directory to clean up." + fi \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b549c29..8d8f813 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,9 +10,84 @@ COPY package*.json ./ # Install dependencies RUN npm install --force +# Local-dev defaults live in .env.local. Next.js loads them at build time +# and inlines every NEXT_PUBLIC_* into the client bundle, so the URLs the +# **browser** ends up calling are frozen here. When deploying on a remote +# host, override these via build args (compose `build.args` or +# `docker build --build-arg ...`); values written to .env.production.local +# below take priority over .env.local during `next build`. +ARG NEXT_PUBLIC_USER_MANAGEMENT_API_BASE= +ARG NEXT_PUBLIC_ML_SERVICE_API_BASE= +ARG NEXT_PUBLIC_TOKEN_ENDPOINT_USER_MANAGEMENT_SERVICE= +ARG NEXT_PUBLIC_TOKEN_ENDPOINT_ML_SERVICE= +ARG NEXT_PUBLIC_TOKEN_ENDPOINT_QUERY_SERVICE= +ARG NEXT_PUBLIC_TOKEN_ENDPOINT_CHAT_SERVICE= +ARG NEXT_PUBLIC_API_QUERY_ENDPOINT= +ARG NEXT_PUBLIC_API_NAMED_GRAPH_QUERY_ENDPOINT= +ARG NEXT_PUBLIC_CHAT_SERVICE_API_ENDPOINT= +ARG NEXT_PUBLIC_API_NER_ENDPOINT= +ARG NEXT_PUBLIC_NER_GET_ENDPOINT= +ARG NEXT_PUBLIC_NER_SAVE_ENDPOINT= +ARG NEXT_PUBLIC_API_PDF2REPROSCHEMA_ENDPOINT= +ARG NEXT_PUBLIC_API_ADMIN_EXTRACT_STRUCTURED_RESOURCE_ENDPOINT= +ARG NEXT_PUBLIC_API_ADMIN_SAVE_STRUCTURED_RESOURCE_ENDPOINT= +ARG NEXT_PUBLIC_API_ADMIN_GET_STRUCTURED_RESOURCE_ENDPOINT= +ARG NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_ENDPOINT= +ARG NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT= +ARG NEXT_PUBLIC_API_ADMIN_INSERT_ALL_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT= +ARG NEXT_PUBLIC_API_ADMIN_INSERT_RECOVERY_JOB_ENDPOINT= +ARG NEXT_PUBLIC_API_ADMIN_INSERT_CHECK_RECOVERABLE_JOB_ENDPOINT= +ARG NEXT_PUBLIC_ENABLE_PAGE_ACCESS_GATE= +ARG NEXTAUTH_URL= + # Copy environment file COPY .env.local ./ +# NextAuth (next-auth/utils/parse-url.js) calls `new URL(NEXTAUTH_URL)` and +# uses `??` for the fallback, which does NOT cover the empty-string case. +# An empty NEXTAUTH_URL → `new URL("")` → ERR_INVALID_URL during prerender. +# Fall back to the local-dev value here so the build never bakes "" into +# the bundle. +ARG _NEXTAUTH_URL_FALLBACK=http://localhost:3000 +RUN if [ -z "$NEXTAUTH_URL" ]; then \ + echo "[build] NEXTAUTH_URL was empty; defaulting to ${_NEXTAUTH_URL_FALLBACK}"; \ + echo "[build] override at deploy time via --build-arg or .env.deploy"; \ + fi +ENV NEXTAUTH_URL=${NEXTAUTH_URL:-$_NEXTAUTH_URL_FALLBACK} + +# Override .env.local with any non-empty build args. Skipping empty values +# means a partial set of overrides still works — anything you don't pass +# falls back to .env.local. +RUN set -e; \ + : > .env.production.local; \ + for v in \ + NEXT_PUBLIC_USER_MANAGEMENT_API_BASE \ + NEXT_PUBLIC_ML_SERVICE_API_BASE \ + NEXT_PUBLIC_TOKEN_ENDPOINT_USER_MANAGEMENT_SERVICE \ + NEXT_PUBLIC_TOKEN_ENDPOINT_ML_SERVICE \ + NEXT_PUBLIC_TOKEN_ENDPOINT_QUERY_SERVICE \ + NEXT_PUBLIC_TOKEN_ENDPOINT_CHAT_SERVICE \ + NEXT_PUBLIC_API_QUERY_ENDPOINT \ + NEXT_PUBLIC_API_NAMED_GRAPH_QUERY_ENDPOINT \ + NEXT_PUBLIC_CHAT_SERVICE_API_ENDPOINT \ + NEXT_PUBLIC_API_NER_ENDPOINT \ + NEXT_PUBLIC_NER_GET_ENDPOINT \ + NEXT_PUBLIC_NER_SAVE_ENDPOINT \ + NEXT_PUBLIC_API_PDF2REPROSCHEMA_ENDPOINT \ + NEXT_PUBLIC_API_ADMIN_EXTRACT_STRUCTURED_RESOURCE_ENDPOINT \ + NEXT_PUBLIC_API_ADMIN_SAVE_STRUCTURED_RESOURCE_ENDPOINT \ + NEXT_PUBLIC_API_ADMIN_GET_STRUCTURED_RESOURCE_ENDPOINT \ + NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_ENDPOINT \ + NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT \ + NEXT_PUBLIC_API_ADMIN_INSERT_ALL_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT \ + NEXT_PUBLIC_API_ADMIN_INSERT_RECOVERY_JOB_ENDPOINT \ + NEXT_PUBLIC_API_ADMIN_INSERT_CHECK_RECOVERABLE_JOB_ENDPOINT \ + NEXT_PUBLIC_ENABLE_PAGE_ACCESS_GATE \ + NEXTAUTH_URL ; do \ + val=$(eval "echo \"\$$v\""); \ + if [ -n "$val" ]; then echo "$v=$val" >> .env.production.local; fi; \ + done + # Copy the rest of the application source code to the working directory COPY . . diff --git a/bin/up-node.sh b/bin/up-node.sh new file mode 100755 index 0000000..7f965ed --- /dev/null +++ b/bin/up-node.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Non-Docker UI deploy for an AWS / VPS box. +# +# Builds the Next.js app from the current checkout and runs it under PM2 on +# the host's Node runtime — useful when you don't want to deal with Docker +# layer-cache + NEXT_PUBLIC_* baking issues. +# +# Pair this with: +# - The BrainKB backend running separately (Docker-compose.unified.yml or +# its own systemd / PM2 setup). +# - An nginx / ALB in front terminating TLS and forwarding to the port +# this app listens on (default 3000, override via PORT). +# +# Usage (run from anywhere): +# ./bin/up-node.sh # build + (re)start +# ./bin/up-node.sh --status # print PM2 status + exit +# ./bin/up-node.sh --logs # tail PM2 logs +# ./bin/up-node.sh --stop # stop the app +# ./bin/up-node.sh --restart # restart without rebuild +# ./bin/up-node.sh --no-install # skip `npm install` +# +# Customise via env vars: +# PM2_APP_NAME=brainkb-ui PM2 process label (default: brainkb-ui) +# PORT=3000 Port `next start` binds +# NODE_ENV=production Forwarded to next start +# USE_NPM_CI=1 Use `npm ci` instead of `npm install --force` +# SKIP_BUILD=1 Don't run `npm run build` (useful in CI) +# +# Idempotent: re-running rebuilds and reloads PM2 in place. PM2's `startup` +# integration is left to the operator (run `pm2 startup` once to install +# the systemd hook so PM2 restarts on reboot). + +set -euo pipefail + +# ── Args ───────────────────────────────────────────────────────────────── +ACTION="deploy" +RUN_INSTALL=1 +for arg in "$@"; do + case "$arg" in + --status) ACTION="status" ;; + --logs) ACTION="logs" ;; + --stop) ACTION="stop" ;; + --restart) ACTION="restart" ;; + --no-install) RUN_INSTALL=0 ;; + -h|--help) + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +# ── Config ─────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UI_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +APP_NAME="${PM2_APP_NAME:-brainkb-ui}" +PORT="${PORT:-3000}" +NODE_ENV_VALUE="${NODE_ENV:-production}" + +# ── Logging helpers ────────────────────────────────────────────────────── +log() { printf '\033[36m[deploy]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[deploy]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[31m[deploy]\033[0m %s\n' "$*" >&2; exit 1; } + +# ── Tooling sanity checks ──────────────────────────────────────────────── +command -v node >/dev/null 2>&1 || fail "node not found in PATH (need >= 18.18 for Next.js 14)" +command -v npm >/dev/null 2>&1 || fail "npm not found in PATH" + +NODE_MAJOR="$(node -v | sed -E 's/^v([0-9]+).*/\1/')" +if [ "${NODE_MAJOR}" -lt 18 ]; then + fail "Node ${NODE_MAJOR} is too old. Install Node 18 or newer (e.g. via nvm)." +fi + +if ! command -v pm2 >/dev/null 2>&1; then + log "pm2 not found — installing globally (this needs sudo or root)" + if ! npm install -g pm2; then + fail "pm2 install failed. Try: 'sudo npm install -g pm2' (or use nvm so npm i -g doesn't need sudo)." + fi +fi + +cd "${UI_DIR}" + +# ── Maintenance actions ────────────────────────────────────────────────── +case "${ACTION}" in + status) + pm2 list + pm2 show "${APP_NAME}" 2>/dev/null || warn "PM2 process '${APP_NAME}' is not running" + exit 0 + ;; + logs) + exec pm2 logs "${APP_NAME}" + ;; + stop) + pm2 stop "${APP_NAME}" 2>/dev/null || warn "no '${APP_NAME}' to stop" + pm2 delete "${APP_NAME}" 2>/dev/null || true + pm2 save --force >/dev/null 2>&1 || true + exit 0 + ;; + restart) + pm2 restart "${APP_NAME}" --update-env || fail "PM2 restart failed (run with no flags to deploy fresh)" + exit 0 + ;; +esac + +# ── Deploy: env file ───────────────────────────────────────────────────── +if [ ! -f "${UI_DIR}/.env.local" ]; then + fail ".env.local missing in ${UI_DIR}. Copy from a teammate or .env.deploy.example and fill it in." +fi + +if ! grep -q '^NEXTAUTH_URL=' "${UI_DIR}/.env.local" \ + || [ -z "$(grep '^NEXTAUTH_URL=' "${UI_DIR}/.env.local" | cut -d= -f2-)" ]; then + warn "NEXTAUTH_URL is missing or empty in .env.local — auth pages will fail." +fi + +# Surface critical NEXT_PUBLIC_* vars at deploy time so we don't silently +# bake "" into the client bundle. Each is only a warning, not a hard fail. +for v in \ + NEXT_PUBLIC_USER_MANAGEMENT_API_BASE \ + NEXT_PUBLIC_ML_SERVICE_API_BASE \ + NEXT_PUBLIC_API_QUERY_ENDPOINT ; do + if ! grep -qE "^${v}=." "${UI_DIR}/.env.local"; then + warn "${v} is missing or empty in .env.local — pages that depend on it will 'environment variable is not set' at runtime." + fi +done + +# ── Deploy: install ────────────────────────────────────────────────────── +if [ "${RUN_INSTALL}" = "1" ]; then + if [ "${USE_NPM_CI:-0}" = "1" ] && [ -f "${UI_DIR}/package-lock.json" ]; then + log "running npm ci" + npm ci + else + log "running npm install --force (override with USE_NPM_CI=1)" + npm install --force + fi +else + log "skipping npm install (--no-install)" +fi + +# ── Deploy: build ──────────────────────────────────────────────────────── +if [ "${SKIP_BUILD:-0}" = "1" ]; then + log "skipping build (SKIP_BUILD=1)" +else + log "running npm run build (NEXT_PUBLIC_* values from .env.local get baked in here)" + NODE_ENV="${NODE_ENV_VALUE}" npm run build +fi + +# ── Deploy: PM2 (re)start ──────────────────────────────────────────────── +# `pm2 startOrReload` would be ideal but needs an ecosystem file; use the +# explicit reload-or-start dance so this works without one. +if pm2 describe "${APP_NAME}" >/dev/null 2>&1; then + log "reloading PM2 process '${APP_NAME}' (zero-downtime)" + PORT="${PORT}" NODE_ENV="${NODE_ENV_VALUE}" \ + pm2 reload "${APP_NAME}" --update-env +else + log "starting PM2 process '${APP_NAME}' on :${PORT}" + PORT="${PORT}" NODE_ENV="${NODE_ENV_VALUE}" \ + pm2 start npm --name "${APP_NAME}" -- start +fi + +pm2 save --force >/dev/null + +log "deploy complete. Status:" +pm2 list + +cat <<'EOF' + +Next steps (one-time): + - Run `pm2 startup` and follow the printed command, so PM2 + your apps + survive reboots via systemd. + - Front this with nginx / ALB so port 3000 isn't exposed publicly. + - To follow logs: ./bin/up-node.sh --logs + - To restart only: ./bin/up-node.sh --restart +EOF diff --git a/bin/up.sh b/bin/up.sh new file mode 100755 index 0000000..4225b0b --- /dev/null +++ b/bin/up.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Bring up the full BrainKB deployment (backend + UI) on a single host. +# +# Order of operations: +# 1. Sanity-check docker / compose are installed and the daemon is up. +# 2. Create the shared `brainkb-network` if it does not yet exist. +# 3. Start the backend stack from BrainKB/docker-compose.unified.yml. +# 4. Wait for the backend's health to go green. +# 5. Build + start the UI stack (this repo) — picks up NEXT_PUBLIC_* from +# the host environment so the client bundle gets baked with public +# URLs the user's browser can actually reach (NOT docker hostnames). +# +# Usage (run from anywhere): +# ./bin/up.sh # auto-detect ../BrainKB +# ./bin/up.sh --with-proxy # also start the nginx reverse proxy +# BRAINKB_DIR=/srv/brainkb ./bin/up.sh +# +# Customise paths/ports/timeouts via env vars at the top of the script. +# All NEXT_PUBLIC_* values you `export` before running are forwarded into +# the UI build args by docker-compose.yml. + +set -euo pipefail + +# ── Args ───────────────────────────────────────────────────────────────── +WITH_PROXY=0 +for arg in "$@"; do + case "$arg" in + --with-proxy) WITH_PROXY=1 ;; + -h|--help) + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +# ── Paths ──────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UI_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +BRAINKB_DIR="${BRAINKB_DIR:-$(cd "${UI_DIR}/../BrainKB" 2>/dev/null && pwd || true)}" +NETWORK_NAME="${BRAINKB_NETWORK:-brainkb-network}" +HEALTH_TIMEOUT_SECS="${HEALTH_TIMEOUT_SECS:-300}" +# Probe usermanagement_service (:8004) by default — that's the supervisor +# program the UI auth flow blocks on. Port 8000 (Django token manager) is +# the unified container's own Docker healthcheck but doesn't tell us +# anything about whether the UI's dependencies are actually up. Override +# with BACKEND_HEALTH_URL=... if you want a different probe. +BACKEND_HEALTH_URL="${BACKEND_HEALTH_URL:-http://localhost:${USERMANAGEMENT_SERVICE_PORT:-8004}/api/auth/providers}" + +# ── Logging helpers ────────────────────────────────────────────────────── +log() { printf '\033[36m[up]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[up]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[31m[up]\033[0m %s\n' "$*" >&2; exit 1; } + +# ── 1. Sanity checks ───────────────────────────────────────────────────── +command -v docker >/dev/null 2>&1 || fail "docker not found in PATH" +docker info >/dev/null 2>&1 || fail "docker daemon is not reachable (is it running?)" + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +else + fail "neither 'docker compose' (v2) nor 'docker-compose' (v1) is available" +fi +log "using compose command: ${COMPOSE[*]}" + +[ -n "${BRAINKB_DIR}" ] && [ -d "${BRAINKB_DIR}" ] \ + || fail "BrainKB backend repo not found. Set BRAINKB_DIR=/path/to/BrainKB" +[ -f "${BRAINKB_DIR}/docker-compose.unified.yml" ] \ + || fail "${BRAINKB_DIR}/docker-compose.unified.yml is missing" +[ -f "${UI_DIR}/docker-compose.yml" ] \ + || fail "${UI_DIR}/docker-compose.yml is missing" + +# Warn loudly if NEXT_PUBLIC_* still default to localhost — the UI bundle +# will be baked with whatever .env.local says, and `localhost` URLs won't +# work for any browser hitting the deployment from outside the host. +if [ -z "${NEXT_PUBLIC_USER_MANAGEMENT_API_BASE:-}" ] \ +&& [ -z "${NEXT_PUBLIC_ML_SERVICE_API_BASE:-}" ]; then + warn "no NEXT_PUBLIC_* overrides exported — the UI will bake .env.local" + warn "values (likely http://localhost:*) into the client bundle." + warn "For an AWS / remote deploy, export the public URLs first, e.g.:" + warn " export NEXT_PUBLIC_USER_MANAGEMENT_API_BASE=https://api.example.com" + warn " export NEXT_PUBLIC_ML_SERVICE_API_BASE=https://api.example.com" + warn " export NEXTAUTH_URL=https://app.example.com" +fi + +# ── 2. Shared network ──────────────────────────────────────────────────── +if docker network inspect "${NETWORK_NAME}" >/dev/null 2>&1; then + log "network '${NETWORK_NAME}' already exists" +else + log "creating network '${NETWORK_NAME}'" + docker network create "${NETWORK_NAME}" >/dev/null +fi + +# ── 3. Backend ─────────────────────────────────────────────────────────── +log "starting backend (${BRAINKB_DIR})" +( cd "${BRAINKB_DIR}" && "${COMPOSE[@]}" -f docker-compose.unified.yml up -d ) + +# ── 4. Wait for backend health ─────────────────────────────────────────── +log "waiting up to ${HEALTH_TIMEOUT_SECS}s for backend at ${BACKEND_HEALTH_URL}" +deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECS )) +while :; do + if curl -sSf -o /dev/null --max-time 5 "${BACKEND_HEALTH_URL}"; then + log "backend is up" + break + fi + if [ "$(date +%s)" -ge "${deadline}" ]; then + warn "backend did not become healthy within ${HEALTH_TIMEOUT_SECS}s" + warn "continuing anyway — check 'docker compose logs' if the UI fails" + break + fi + sleep 3 +done + +# ── 5. UI (+ optional nginx proxy) ─────────────────────────────────────── +UI_COMPOSE_ARGS=(-f docker-compose.yml) +if [ "${WITH_PROXY}" = "1" ]; then + [ -f "${UI_DIR}/docker-compose.proxy.yml" ] \ + || fail "--with-proxy requested but docker-compose.proxy.yml is missing" + [ -f "${UI_DIR}/nginx/nginx.conf" ] \ + || fail "--with-proxy requested but nginx/nginx.conf is missing" + UI_COMPOSE_ARGS+=(-f docker-compose.proxy.yml) + log "building + starting UI + proxy (${UI_DIR})" +else + log "building + starting UI (${UI_DIR})" +fi +( cd "${UI_DIR}" && "${COMPOSE[@]}" "${UI_COMPOSE_ARGS[@]}" up -d --build ) + +log "all stacks are up. Status:" +( cd "${BRAINKB_DIR}" && "${COMPOSE[@]}" -f docker-compose.unified.yml ps ) +( cd "${UI_DIR}" && "${COMPOSE[@]}" "${UI_COMPOSE_ARGS[@]}" ps ) diff --git a/docker-compose-sandbox.yml b/docker-compose-sandbox.yml index 8e5e42f..62320d1 100644 --- a/docker-compose-sandbox.yml +++ b/docker-compose-sandbox.yml @@ -1,9 +1,23 @@ -version: '3' +# Sandbox compose for the brainkb-ui — meant for iterating on the UI against +# a running BrainKB backend stack on the same host. Mounts the source tree +# into the container so you can edit files locally and re-run, without +# rebuilding the image. Use the production compose (docker-compose.yml) for +# real deployments — that one strips the volume overlay and lets you bake +# real public URLs at build time via NEXT_PUBLIC_* build args. +# +# Bring it up alongside the backend (which lives on the external +# `brainkb-network`) so the UI container can reach service hostnames like +# `brainkb-unified`. Create the network once if it does not yet exist: +# docker network create brainkb-network + services: sandbox_web: build: . ports: - - '80:3000' # Maps host:container port + # Default to 3000 to avoid clashing with anything else listening on + # host port 80 (reverse proxies, etc). Override via the + # SANDBOX_WEB_HOST_PORT env var when you need it on 80. + - '${SANDBOX_WEB_HOST_PORT:-3000}:3000' env_file: - .env.local environment: @@ -11,4 +25,12 @@ services: volumes: - .:/app - /app/node_modules - - /app/.next \ No newline at end of file + - /app/.next + restart: unless-stopped + networks: + - brainkb-network + +networks: + brainkb-network: + external: true + name: brainkb-network diff --git a/docker-compose.proxy.yml b/docker-compose.proxy.yml new file mode 100644 index 0000000..01da1ca --- /dev/null +++ b/docker-compose.proxy.yml @@ -0,0 +1,30 @@ +# Optional reverse-proxy overlay. Stack it on top of docker-compose.yml to +# put nginx in front of the UI + backend services on a single port: +# +# docker compose -f docker-compose.yml -f docker-compose.proxy.yml \ +# up -d --build +# +# Or run via `bin/up.sh --with-proxy`. +# +# This is meant for single-host deploys (an EC2 box, a homelab box). On AWS +# you would normally let an ALB / CloudFront terminate TLS and forward to +# this nginx on plain HTTP — set ALB rules to route `*` to the proxy port. + +services: + proxy: + image: nginx:1.25-alpine + container_name: brainkb-proxy + ports: + - '${PROXY_HOST_PORT:-80}:80' + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + networks: + - brainkb-network + restart: unless-stopped + depends_on: + - web + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index e8ba2d3..24a7776 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,62 @@ -version: '3' +# Production compose for the brainkb-ui Next.js app. +# +# Networking +# Joins the external `brainkb-network` so the UI container can reach the +# BrainKB backend stack (docker-compose.unified.yml). Create the network +# once before bringing up either stack: +# docker network create brainkb-network +# Both stacks declare it as `external: true` and will fail fast if absent. +# +# NEXT_PUBLIC_* build args +# Next.js inlines NEXT_PUBLIC_* into the client bundle at build time, so +# these are URLs the user's **browser** ends up calling — they MUST be +# reachable from the user's machine, not docker-internal hostnames. When +# the UI is deployed on a remote host, set these in your shell (or in a +# sibling .env file) before running `docker compose up --build`: +# export NEXT_PUBLIC_USER_MANAGEMENT_API_BASE=https://api.example.com +# export NEXT_PUBLIC_ML_SERVICE_API_BASE=https://api.example.com +# ... +# Anything left unset falls back to whatever is in .env.local. services: web: - build: . + build: + context: . + args: + NEXT_PUBLIC_USER_MANAGEMENT_API_BASE: ${NEXT_PUBLIC_USER_MANAGEMENT_API_BASE-} + NEXT_PUBLIC_ML_SERVICE_API_BASE: ${NEXT_PUBLIC_ML_SERVICE_API_BASE-} + NEXT_PUBLIC_TOKEN_ENDPOINT_USER_MANAGEMENT_SERVICE: ${NEXT_PUBLIC_TOKEN_ENDPOINT_USER_MANAGEMENT_SERVICE-} + NEXT_PUBLIC_TOKEN_ENDPOINT_ML_SERVICE: ${NEXT_PUBLIC_TOKEN_ENDPOINT_ML_SERVICE-} + NEXT_PUBLIC_TOKEN_ENDPOINT_QUERY_SERVICE: ${NEXT_PUBLIC_TOKEN_ENDPOINT_QUERY_SERVICE-} + NEXT_PUBLIC_TOKEN_ENDPOINT_CHAT_SERVICE: ${NEXT_PUBLIC_TOKEN_ENDPOINT_CHAT_SERVICE-} + NEXT_PUBLIC_API_QUERY_ENDPOINT: ${NEXT_PUBLIC_API_QUERY_ENDPOINT-} + NEXT_PUBLIC_API_NAMED_GRAPH_QUERY_ENDPOINT: ${NEXT_PUBLIC_API_NAMED_GRAPH_QUERY_ENDPOINT-} + NEXT_PUBLIC_CHAT_SERVICE_API_ENDPOINT: ${NEXT_PUBLIC_CHAT_SERVICE_API_ENDPOINT-} + NEXT_PUBLIC_API_NER_ENDPOINT: ${NEXT_PUBLIC_API_NER_ENDPOINT-} + NEXT_PUBLIC_NER_GET_ENDPOINT: ${NEXT_PUBLIC_NER_GET_ENDPOINT-} + NEXT_PUBLIC_NER_SAVE_ENDPOINT: ${NEXT_PUBLIC_NER_SAVE_ENDPOINT-} + NEXT_PUBLIC_API_PDF2REPROSCHEMA_ENDPOINT: ${NEXT_PUBLIC_API_PDF2REPROSCHEMA_ENDPOINT-} + NEXT_PUBLIC_API_ADMIN_EXTRACT_STRUCTURED_RESOURCE_ENDPOINT: ${NEXT_PUBLIC_API_ADMIN_EXTRACT_STRUCTURED_RESOURCE_ENDPOINT-} + NEXT_PUBLIC_API_ADMIN_SAVE_STRUCTURED_RESOURCE_ENDPOINT: ${NEXT_PUBLIC_API_ADMIN_SAVE_STRUCTURED_RESOURCE_ENDPOINT-} + NEXT_PUBLIC_API_ADMIN_GET_STRUCTURED_RESOURCE_ENDPOINT: ${NEXT_PUBLIC_API_ADMIN_GET_STRUCTURED_RESOURCE_ENDPOINT-} + NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_ENDPOINT: ${NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_ENDPOINT-} + NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT: ${NEXT_PUBLIC_API_ADMIN_INSERT_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT-} + NEXT_PUBLIC_API_ADMIN_INSERT_ALL_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT: ${NEXT_PUBLIC_API_ADMIN_INSERT_ALL_KGS_JSONLD_TTL_JOB_STATUS_ENDPOINT-} + NEXT_PUBLIC_API_ADMIN_INSERT_RECOVERY_JOB_ENDPOINT: ${NEXT_PUBLIC_API_ADMIN_INSERT_RECOVERY_JOB_ENDPOINT-} + NEXT_PUBLIC_API_ADMIN_INSERT_CHECK_RECOVERABLE_JOB_ENDPOINT: ${NEXT_PUBLIC_API_ADMIN_INSERT_CHECK_RECOVERABLE_JOB_ENDPOINT-} + NEXT_PUBLIC_ENABLE_PAGE_ACCESS_GATE: ${NEXT_PUBLIC_ENABLE_PAGE_ACCESS_GATE-} + NEXTAUTH_URL: ${NEXTAUTH_URL-} ports: - - '3000:3000' # Maps host:container port + - '${WEB_HOST_PORT:-3000}:3000' env_file: - .env.local environment: - NODE_ENV=production - volumes: - - .:/app - - /app/node_modules - - /app/.next + restart: unless-stopped networks: - brainkb-network + networks: brainkb-network: - external: true \ No newline at end of file + external: true + name: brainkb-network diff --git a/lib/auth.ts b/lib/auth.ts index 5f31240..fa121b7 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,24 +1,105 @@ -import GithubProvider from "next-auth/providers/github"; -import ORCIDProvider from "@/lib/orcid_provider"; +// NextAuth now wraps a backend-issued JWT instead of running GitHub/ORCID +// OAuth in the browser. The usermanagement_service handles all OAuth providers +// (GitHub, ORCID, Globus) server-side and redirects back to /auth/callback with +// `?token=`. That page calls `signIn('backend-jwt', { token })`, which +// hits the CredentialsProvider below. + +import CredentialsProvider from "next-auth/providers/credentials"; import crypto from "crypto"; +import type { NextAuthOptions } from "next-auth"; -// Generate a strong fallback secret -const generateFallbackSecret = () => { - return crypto.randomBytes(32).toString('base64'); -}; +const generateFallbackSecret = () => crypto.randomBytes(32).toString("base64"); + +// Base URL of the user management backend (server-side; does not need to be public). +// Falls back to deriving from the public token endpoint so existing setups keep working. +function userManagementBaseUrl(): string { + const explicit = process.env.USER_MANAGEMENT_API_BASE || process.env.NEXT_PUBLIC_USER_MANAGEMENT_API_BASE; + if (explicit) return explicit.replace(/\/+$/, ""); + const tokenEndpoint = process.env.NEXT_PUBLIC_TOKEN_ENDPOINT_USER_MANAGEMENT_SERVICE; + if (tokenEndpoint) { + try { + const u = new URL(tokenEndpoint); + return `${u.protocol}//${u.host}`; + } catch { + /* ignored */ + } + } + return "http://localhost:8004"; +} -export const authOptions = { - // https://github.com/nextauthjs/next-auth/pull/3143 - secret: process.env.NEXTAUTH_SECRET || generateFallbackSecret(), +export const authOptions: NextAuthOptions = { + secret: process.env.NEXTAUTH_SECRET, + session: { strategy: "jwt" }, providers: [ - GithubProvider({ - clientId: process.env.GITHUB_CLIENT_ID as string, - clientSecret: process.env.GITHUB_CLIENT_SECRET as string, - }), - ORCIDProvider({ - clientId: process.env.ORCID_CLIENT_ID as string, - clientSecret: process.env.ORCID_CLIENT_SECRET as string, + CredentialsProvider({ + id: "backend-jwt", + name: "BrainKB Backend", + credentials: { + token: { label: "Backend JWT", type: "text" }, + }, + async authorize(credentials) { + const token = credentials?.token; + if (!token) return null; + + // Validate the token by asking the backend who it belongs to. This avoids + // shipping the backend JWT secret to Next.js and gives us the full claim + // set (profile_id, roles, auth_source) in one call. + try { + const res = await fetch(`${userManagementBaseUrl()}/api/users/me`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return null; + const me = await res.json(); + return { + id: String(me.profile_id ?? me.user_id ?? me.email), + email: me.email ?? null, + name: me.name ?? me.email ?? null, + orcid_id: me.orcid_id ?? null, + backendToken: token, + profileId: me.profile_id ?? null, + userId: me.user_id ?? null, + roles: me.roles ?? [], + scopes: me.scopes ?? [], + authSource: me.auth_source ?? "password", + } as any; + } catch (err) { + console.error("[next-auth] backend JWT validation failed:", err); + return null; + } + }, }), ], + callbacks: { + async jwt({ token, user }) { + // On first sign-in, hydrate the NextAuth JWT with backend claims. + if (user) { + const u = user as any; + token.backendToken = u.backendToken; + token.profileId = u.profileId; + token.userId = u.userId; + token.roles = u.roles ?? []; + token.scopes = u.scopes ?? []; + token.authSource = u.authSource ?? "password"; + (token as any).orcid_id = u.orcid_id ?? null; + } + return token; + }, + async session({ session, token }) { + const s = session as any; + s.backendToken = token.backendToken; + s.profileId = token.profileId; + s.userId = token.userId; + s.roles = token.roles ?? []; + s.scopes = token.scopes ?? []; + s.authSource = token.authSource ?? "password"; + if (s.user) { + s.user.orcid_id = (token as any).orcid_id ?? null; + } + return session; + }, + }, + pages: { + signIn: "/", + }, }; - diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..b85e675 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,66 @@ +# Thin reverse proxy for the BrainKB UI. +# +# This nginx fronts ONLY the Next.js UI on a friendly port (default 80) so +# you don't have to expose `:3000` directly to users. The BrainKB backend +# services keep their own host-mapped ports from docker-compose.unified.yml +# and are reached by the browser at: +# +# http://:8004 user-management (auth, users, admin, RBAC) +# http://:8007 ml service (SynthScholar, NER, struct.res) +# http://:8010 query service (SPARQL, KG ingest) +# http://:8011 chat service (optional) +# +# That matches the .env.local / .env.deploy.example layout — each +# NEXT_PUBLIC_* URL points at its service's default port, no prefix +# rewriting. If you later want to consolidate everything behind one origin +# (e.g. for TLS via a single cert), add `location // { proxy_pass ... }` +# blocks per service and update the env vars to match. + +worker_processes auto; +events { worker_connections 4096; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + # WebSocket / SSE upgrade map. Most "regular" requests have no Upgrade + # header, in which case we want Connection: close (not "upgrade"). + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # Generous body size — the UI accepts file uploads (KG triples, PDFs). + client_max_body_size 200M; + + # Use Docker's embedded DNS so the upstream is resolved lazily; this + # lets nginx start even if the UI container is briefly down. + resolver 127.0.0.11 valid=30s ipv6=off; + + # Common proxy headers — keep `X-Forwarded-Proto` accurate for the UI's + # NextAuth callbacks (it builds OAuth callback URLs from these). + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + server { + listen 80 default_server; + server_name _; + + location / { + set $upstream_ui http://web:3000; + proxy_pass $upstream_ui; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + # Next.js streams responses for RSC / dynamic routes; don't + # buffer them, otherwise the page hangs until the full reply + # is collected by nginx. + proxy_buffering off; + } + } +} diff --git a/package-lock.json b/package-lock.json index 09ee9e1..2b8ad3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.83.0", "@types/d3": "^7.4.3", "@types/form-data": "^2.5.2", "@types/js-yaml": "^4.0.9", @@ -1863,6 +1864,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.100.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.6.tgz", + "integrity": "sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.6.tgz", + "integrity": "sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", diff --git a/package.json b/package.json index 2877bfe..52259e1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.83.0", "@types/d3": "^7.4.3", "@types/form-data": "^2.5.2", "@types/js-yaml": "^4.0.9", diff --git a/src/app/Footer.tsx b/src/app/Footer.tsx index 3178112..0209006 100644 --- a/src/app/Footer.tsx +++ b/src/app/Footer.tsx @@ -1,53 +1,133 @@ "use client"; -import {useState} from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -const Footer: React.FC = () => { - const [isOpen, setIsOpen] = useState(false); +/** + * Footer — ported from sensein/brainkb-ui-prototype's Landing footer. + * + * Wraps itself in so the --bkb-* tokens resolve regardless of where + * the parent renders this (the root layout drops it after every route). + */ + +import React from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { FONTS, Theme } from "@/src/app/components/design-system"; - return ( -
-
-
- - BrainKB Logo - BrainKB - -
    -
  • - - About - -
  • -
  • - - Privacy Policy - -
  • -
  • - - Contact - -
  • -
+const COLUMNS: { h: string; items: { label: string; href?: string }[] }[] = [ +// { +// h: "Product", +// items: [ +// { label: "Explorer", href: "/knowledge-base" }, +// { label: "Dashboard", href: "/user/dashboard" }, +// { label: "SPARQL API", href: "/about" }, +// { label: "Changelog", href: "/about" }, +// ], +// }, + { + h: "Resources", + items: [ + { label: "Documentation", href: "http://docs.brainkb.org" }, + { label: "Ontologies", href: "https://brain-bican.github.io/models/" }, + { label: "Data sources", href: "/data-release" }, + ], + }, + { + h: "About", + items: [ +// { label: "Team", href: "/about" }, +// { label: "Governance", href: "/about" }, + { label: "Privacy", href: "/privacy-policy" }, + { label: "Contact", href: "/contact" }, + ], + }, +]; + +const Footer: React.FC = () => { + const year = new Date().getFullYear(); + return ( + +
+
+
+
+ BrainKB +
BrainKB
+
+
+ An open neuroscience knowledge graph supported by U24MH130918 (BICAN Knowledgebase), P41EB019936 (ReproNim), U24MH136628 (BBQS), UM1NS132358 (Brain Connects), and MIT MGAIC consortium. +
+
+ {COLUMNS.map((col) => ( +
+
+ {col.h} +
+ {col.items.map((it) => ( +
+ {it.href ? ( + + {it.label} + + ) : ( + {it.label} + )}
-
- - © 2024 - {new Date().getFullYear()} BrainKB & Senseable Intelligence Group. All Rights Reserved. - + ))}
-
- ); + ))} +
+ +
+ © {year} BrainKB ·{" "} + + Senseable Intelligence Group + +
+
+
+ ); }; export default Footer; diff --git a/src/app/admin/AdminShell.tsx b/src/app/admin/AdminShell.tsx new file mode 100644 index 0000000..86481c0 --- /dev/null +++ b/src/app/admin/AdminShell.tsx @@ -0,0 +1,151 @@ +"use client"; + +/** + * AdminShell — sidebar + content layout for /admin/*. + * + * Gates the entire surface behind the Admin role. While useCurrentUser is + * still loading, shows a skeleton (rather than flashing a 403 to a user + * who is in fact admin). Non-admins get a denied page. + */ + +import React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { FONTS, Icon, Logo } from "@/src/app/components/design-system"; +import { useCurrentUser } from "@/src/hooks/useCurrentUser"; + +const ADMIN_NAV: { href: string; label: string; icon: string }[] = [ + { href: "/admin/dashboard", label: "Statistics", icon: "dash" }, + { href: "/admin/users", label: "Users", icon: "person" }, + { href: "/admin/roles", label: "Roles & permissions", icon: "shield" }, + { href: "/admin/page-access", label: "Page access", icon: "lock" }, + { href: "/admin/guide", label: "Guide", icon: "info" }, +]; + +function AdminSidebar() { + const pathname = usePathname() || "/admin/dashboard"; + const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/"); + return ( + + ); +} + +function SuspendedNotice({ reason, bannedAt }: { reason: string | null; bannedAt: string | null }) { + return ( +
+
+ Account suspended +
+

+ Your account has been suspended +

+

+ {reason + ? <>An administrator suspended this account with the following reason: {reason} + : "An administrator suspended this account."} + {bannedAt && ( + <> + {" "}Effective {new Date(bannedAt).toLocaleString()}. + + )} +

+

+ If you believe this is an error, contact a platform administrator. +

+
+ ); +} + +function Denied({ reason }: { reason: string }) { + return ( +
+
+ Forbidden +
+

+ Admin access required +

+

{reason}

+ + Back to dashboard + +
+ ); +} + +export function AdminShell({ children }: { children: React.ReactNode }) { + const { user, loading, isAdmin, banned } = useCurrentUser(); + + if (loading) { + return ( +
Checking access…
+ ); + } + + if (banned) { + return ; + } + + if (!user) { + return ; + } + + if (!isAdmin) { + return ( + + ); + } + + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..36f8189 --- /dev/null +++ b/src/app/admin/dashboard/page.tsx @@ -0,0 +1,375 @@ +"use client"; + +/** + * /admin — Statistics landing. + * + * Pulls a few cheap counts from the backend (users, roles, permissions, + * page-access entries) and surfaces the current admin's identity. Each + * card links into the relevant management surface. + */ + +import React from "react"; +import Link from "next/link"; +import { FONTS, Icon } from "@/src/app/components/design-system"; +import { adminApi, type AvailableRole, type SharedOpenRouterKeyAdminView } from "@/src/services/api/userManagement"; +import { useCurrentUser } from "@/src/hooks/useCurrentUser"; + +interface Counts { + users: number | null; + roles: number | null; + permissions: number | null; + pages: number | null; +} + +export default function AdminStatsPage() { + const { user } = useCurrentUser(); + const [counts, setCounts] = React.useState({ users: null, roles: null, permissions: null, pages: null }); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const [usersC, roles, perms, pages] = await Promise.all([ + adminApi.countUsers().catch(() => ({ count: 0 })), + adminApi.listRoles().catch(() => []), + adminApi.listPermissions().catch(() => []), + adminApi.listPageAccess().catch(() => []), + ]); + if (cancelled) return; + setCounts({ users: usersC.count, roles: roles.length, permissions: perms.length, pages: pages.length }); + } catch (e: any) { + if (!cancelled) setError(e?.message ?? "request failed"); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+

+ Statistics +

+
+ Manage users, roles, permissions, and page-level access for BrainKB. +
+ +
+ {[ + { n: counts.users, l: "Users", icon: "person", c: "var(--bkb-agent)", href: "/admin/users" }, + { n: counts.roles, l: "Roles", icon: "shield", c: "var(--bkb-primary)", href: "/admin/roles" }, + { n: counts.permissions, l: "Permissions", icon: "key", c: "var(--bkb-evidence)", href: "/admin/roles" }, + { n: counts.pages, l: "Pages registered", icon: "lock", c: "var(--bkb-publication)", href: "/admin/page-access" }, + ].map((s) => ( + +
+ +
+
+
+ {s.l} +
+
+ {s.n ?? "—"} +
+
+ + ))} +
+ + {error && ( +
+
Failed to reach the user management API.
+
{error}
+
+ )} + + + +
+
Signed in as
+
+ {[ + { l: "Email", v: user?.email ?? "—" }, + { l: "Profile ID", v: user?.profile_id != null ? String(user.profile_id) : "—" }, + { l: "Auth source", v: user?.auth_source ?? "—" }, + { l: "Roles", v: user?.roles?.join(", ") || "—" }, + { l: "Scopes", v: user?.scopes?.join(", ") || "—" }, + { l: "User ID", v: user?.user_id != null ? String(user.user_id) : "—" }, + ].map((h) => ( +
+
{h.l}
+
{h.v}
+
+ ))} +
+
+
+ ); +} + +// ============================================================================= +// Shared OpenRouter API key +// ============================================================================= +// Admin-only card to set / replace / clear the shared OpenRouter key. End +// users get the plaintext via /api/settings/openrouter-key/effective for use +// in browser API calls — it's never displayed to non-admins. The admin can +// reveal the plaintext on demand to copy/audit it (`reveal=true` query +// param). Stored encrypted at rest with the same Fernet key as OAuth tokens. + +function SharedOpenRouterKeyCard() { + const [view, setView] = React.useState(null); + const [roles, setRoles] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [editing, setEditing] = React.useState(false); + const [draftKey, setDraftKey] = React.useState(""); + const [draftRoles, setDraftRoles] = React.useState>(new Set()); + const [revealed, setRevealed] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + const [notice, setNotice] = React.useState(null); + + const reload = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [v, r] = await Promise.all([ + adminApi.getOpenRouterKey().catch(() => null), + adminApi.listRoles().catch(() => [] as AvailableRole[]), + ]); + setView(v); + setRoles(r); + setDraftRoles(new Set(v?.allowed_role_names ?? [])); + } catch (e: any) { + setError(e?.message ?? "request failed"); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { void reload(); }, [reload]); + + async function handleReveal() { + setBusy(true); + setError(null); + try { + const v = await adminApi.getOpenRouterKey(true); + setRevealed(v.plaintext); + } catch (e: any) { + setError(e?.message ?? "request failed"); + } finally { + setBusy(false); + } + } + + async function handleSave() { + if (!draftKey.trim()) { + setError("Paste a key before saving."); + return; + } + setBusy(true); + setError(null); + try { + await adminApi.setOpenRouterKey({ + api_key: draftKey.trim(), + allowed_role_names: draftRoles.size === 0 ? null : Array.from(draftRoles), + }); + setNotice("Shared key saved. Users will receive it on their next page load."); + setDraftKey(""); + setRevealed(null); + setEditing(false); + await reload(); + } catch (e: any) { + setError(e?.message ?? "save failed"); + } finally { + setBusy(false); + } + } + + async function handleClear() { + if (!confirm("Clear the shared OpenRouter key? Users will fall back to their own key.")) return; + setBusy(true); + setError(null); + try { + await adminApi.deleteOpenRouterKey(); + setNotice("Shared key cleared."); + setRevealed(null); + await reload(); + } catch (e: any) { + setError(e?.message ?? "clear failed"); + } finally { + setBusy(false); + } + } + + function toggleRole(name: string) { + setDraftRoles((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + return ( +
+
+
+
Shared OpenRouter API key
+
+ Provided to users for SIE / Resource extraction unless they set their own. Encrypted at rest. +
+
+ {view?.has_key && ( + + Set · ends in {view.last_4} + + )} +
+ + {loading ? ( +
Loading…
+ ) : ( + <> +
+ {view?.has_key ? ( + <> + Last updated{" "} + + {view.updated_at ? new Date(view.updated_at).toLocaleString() : "—"} + + {" "}·{" "} + {view.allowed_role_names && view.allowed_role_names.length > 0 + ? <>Available to roles: {view.allowed_role_names.join(", ")} + : <>Available to any signed-in user} + + ) : ( + <>No shared key set. Users must paste their own on the dashboard. + )} +
+ + {revealed !== null && ( +
+ {revealed} +
+ +
+
+ )} + + {!editing && ( +
+ + {view?.has_key && ( + <> + + + + )} +
+ )} + + {editing && ( +
+
+ + setDraftKey(e.target.value)} + /> +
+
+
+ Roles allowed to use this key (no roles selected = any signed-in user) +
+
+ {roles.map((r) => { + const on = draftRoles.has(r.name); + return ( + + ); + })} +
+
+
+ + +
+
+ )} + + {error &&
{error}
} + {notice &&
{notice}
} + + )} +
+ ); +} diff --git a/src/app/admin/guide/page.tsx b/src/app/admin/guide/page.tsx new file mode 100644 index 0000000..65c3e1e --- /dev/null +++ b/src/app/admin/guide/page.tsx @@ -0,0 +1,375 @@ +"use client"; + +/** + * /admin/guide — In-app reference for the admin surfaces. + * + * Mirrors the operator-facing sections of brainkb-ui/README.md so admins can + * find answers without leaving the app. Sections are anchor-linked so a row + * in the table of contents jumps to the relevant block. + */ + +import React from "react"; +import Link from "next/link"; +import { FONTS, Icon } from "@/src/app/components/design-system"; + +interface Section { + id: string; + title: string; + icon: string; + body: React.ReactNode; +} + +const SECTIONS: Section[] = [ + { + id: "users", + title: "Managing users", + icon: "person", + body: ( + <> +

+ Open /admin/users. The list shows every + UserProfile created by an OAuth sign-in, with their email, ORCID, providers, and roles. +

+
    +
  • + Search by name, email, or ORCID. Filter by role using + the dropdown. +
  • +
  • + Add a role: in the Roles column, pick from the + + add role dropdown. Custom roles you've defined under Roles & permissions + also appear here. +
  • +
  • + Remove a role: click the RoleName ✕ chip on the row. +
  • +
  • + Delete a user: red Delete button at the row's right edge — + cascades through their activities, role assignments, and OAuth identities. +
  • +
+

+ For the first admin: set + USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS=you@example.com in the backend's + .env, restart the backend. The seeded account receives both + the Admin role (for permissions) and the SuperAdmin role + (the protected marker — can't be banned, deleted, or stripped via the UI). + After that admin signs in, /admin/users is the canonical + place to grant the regular Admin role to anyone else. +

+ + ), + }, + { + id: "roles", + title: "Roles & permissions", + icon: "shield", + body: ( + <> +

+ Open /admin/roles. Two layers: +

+
    +
  • + Roles: Admin, Curator, Reviewer, etc. Plus any custom roles you + create (e.g. MIT User, Lab X Member). +
  • +
  • + Permissions: fine-grained resource.action capabilities + (user.read, role.assign, …). The Admin role is + granted every permission on backend startup. +
  • +
+

Canonical vs custom roles

+

+ The 13 canonical roles (Admin, Submitter, Annotator, Mapper, Curator, Reviewer, + Validator, Conflict Resolver, Knowledge Contributor, Evidence Tracer, + Provenance Tracker, Moderator, Ambassador) are referenced in code — + don't rename them. Bootstrap re-seeds any missing canonical roles on backend start. +

+

+ Custom roles are organisational labels that don't get referenced by + name in code. Format rules: must start with a letter; letters / numbers / spaces / + hyphens / underscores only; max 100 chars. Same rules for category (max 50 chars). +

+

Mapping permissions to roles

+

+ On a role's row, click Edit permissions to multi-select the permissions the + role should grant. The Admin role's permissions are baked in by bootstrap and can't be + unset from the UI. +

+ + ), + }, + { + id: "page-access", + title: "Page access (per-tool RBAC)", + icon: "lock", + body: ( + <> +

+ Open /admin/page-access. Each + UI tool page has a page_key (e.g. tools.ingest-kg); this surface + maps each key to the roles and individual users that may reach it. +

+

Default-deny

+

+ Tools without an entry are denied to non-admins. Admins always pass the + gate regardless — the check short-circuits on + isAdmin, so deleting an + admin.* entry never locks an Admin out (and bootstrap re-seeds those rows on + every backend start anyway). The X button on admin.* rows is therefore + disabled. +

+

Granting tool access to a role

+
    +
  1. Find the page in the Registered pages list, or click the unregistered-tool + chip in the banner to seed a new entry.
  2. +
  3. In the editor, click each role chip under Allowed roles that should pass.
  4. +
  5. Optionally paste comma- or newline-separated emails under User-email overrides + to grant access to specific users regardless of their role.
  6. +
  7. Save.
  8. +
+

Public pages

+

+ Toggle Public if anonymous users should reach the page (rare for workflow tools — + usually only for content browsing pages). The home entry is the typical example. +

+

Registering a brand-new tool

+

When you add a new tool page in the codebase:

+
    +
  1. Wrap the page in {``}.
  2. +
  3. Add an entry to src/config/toolRegistry.ts with the same{" "} + pageKey.
  4. +
  5. Open this page (page-access), seed via the unregistered banner, assign roles, save.
  6. +
  7. (Optional) bake a default into bootstrap.py::seed_default_page_access for + fresh deployments.
  8. +
+

Locking a tool to admin-only

+

+ Wrap the tool page in {``} and set + adminOnly: true on the matching toolRegistry.ts entry. The gate + skips the backend RBAC check and only verifies isAdmin; the unregistered-tool + banner here will skip it. No DB entry needed. +

+ + ), + }, + { + id: "bans", + title: "Suspending users", + icon: "lock", + body: ( + <> +

+ Suspend a user without deleting their profile when you need to block their access but + keep their history (contributions, OAuth identities, role assignments). Lifting the + suspension restores access immediately — no re-OAuth required. +

+

Suspending

+
    +
  1. Open /admin/users.
  2. +
  3. On the user's row, click Ban.
  4. +
  5. Enter a reason (required; up to 1000 chars). Stored on the profile, surfaced + on the row's "banned" chip tooltip, and recorded in the activity log under + USER_BAN.
  6. +
+

What changes for the suspended user

+
    +
  • Every authenticated request returns 403 — the + get_current_user dependency re-reads is_banned on each + request, so the ban takes effect immediately, even on existing JWTs.
  • +
  • The user's UI shows a clean Account suspended notice with the + reason instead of the dashboard / tool pages.
  • +
  • Public pages they could read while signed out remain readable — only + authenticated actions are blocked.
  • +
+

Lifting

+

+ On the suspended user's row, click Unban. Access is restored on + the next request; the action is logged as USER_UNBAN. +

+

Refusals — by design

+
    +
  • Can't ban yourself. The endpoint refuses the request.
  • +
  • Can't ban a SuperAdmin. SuperAdmin accounts are + seeded from USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS and + protected against ban, delete, and role-strip. Other Admins are + bannable directly — multiple admins coexist and can moderate each + other.
  • +
  • Banning is reversible; deleting is not. Use Delete only + for spam / invalid accounts where you don't need the audit trail.
  • +
+

IP / IP-range bans

+

+ Not implemented at the application layer. Recommendation is to handle IP blocks at + the WAF / reverse-proxy layer (Cloudflare rules, nginx geo+deny, + AWS WAF) since proxies, NAT, IPv6, and mobile carriers make app-layer enforcement + fragile and easy to bypass. +

+ + ), + }, + { + id: "shared-api-key", + title: "Shared OpenRouter API key", + icon: "key", + body: ( + <> +

+ Configure on the Statistics page, + under Shared OpenRouter API key. +

+

Setting the key

+
    +
  1. Click Set shared key (or Replace key if one exists).
  2. +
  3. Paste the OpenRouter key. Optionally select roles allowed to consume it — empty + selection means any signed-in user.
  4. +
  5. Save. Encrypted at rest using the same Fernet key as OAuth tokens + (USERMANAGEMENT_OAUTH_TOKEN_ENC_KEY).
  6. +
+

Visibility rules

+
    +
  • Admins can reveal the plaintext via the Reveal current + button.
  • +
  • End users in an allowed role can use the key but never + see plaintext — the dashboard input shows "Shared admin key in use" and + forwards the key directly to OpenRouter.
  • +
  • Users not in an allowed role get no key from the effective endpoint + and must paste their own.
  • +
+

User precedence

+

+ A user's own personal key (entered on their dashboard) always overrides the shared key + for their session. Tools resolve as: personal → shared → none. +

+ + ), + }, + { + id: "env", + title: "Backend env vars (cheat sheet)", + icon: "settings", + body: ( + <> +

+ Set in BrainKB/.env. Restart the backend after editing — env is read once at + startup. +

+ + + + + + + + + {[ + ["USERMANAGEMENT_PUBLIC_BASE_URL", "Backend's public URL — used for OAuth redirect_uri"], + ["USERMANAGEMENT_FRONTEND_CALLBACK_URL", "Where the backend redirects users after OAuth"], + ["USERMANAGEMENT_OAUTH_TOKEN_ENC_KEY", "Fernet key for encrypting OAuth tokens + shared API keys at rest"], + ["USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS", "Comma-separated emails granted SuperAdmin (+Admin) on backend startup. Protected from ban/delete/role-strip."], + ["GITHUB_CLIENT_ID / _SECRET", "GitHub OAuth credentials (set on backend, NOT UI)"], + ["ORCID_CLIENT_ID / _SECRET", "ORCID OAuth credentials"], + ["GLOBUS_CLIENT_ID / _SECRET", "Globus OAuth credentials"], + ].map(([k, v]) => ( + + + + + ))} + +
VariablePurpose
{k}{v}
+

+ OAuth provider redirect URLs are configured in each provider's developer console and must + point at the backend (e.g.{" "} + http://localhost:8004/api/auth/github/callback), not the UI. +

+ + ), + }, + { + id: "diagnostics", + title: "Diagnostics", + icon: "info", + body: ( + <> +

Quick curl checks to confirm the backend is wired correctly:

+
+{`# OAuth providers — should show \`configured: true\` for at least one
+curl -s http://localhost:8004/api/auth/providers | jq
+
+# Your roles, profile_id, scopes (replace $JWT)
+curl -s -H "Authorization: Bearer $JWT" http://localhost:8004/api/users/me | jq
+
+# Your effective shared OpenRouter key (or \`source: none\`)
+curl -s -H "Authorization: Bearer $JWT" \\
+  http://localhost:8004/api/settings/openrouter-key/effective | jq
+
+# Page access for a specific tool
+curl -s -H "Authorization: Bearer $JWT" \\
+  http://localhost:8004/api/access/page/tools.ingest-kg | jq`}
+        
+ + ), + }, +]; + +export default function AdminGuidePage() { + return ( +
+

+ Admin guide +

+
+ How to configure users, roles, page access, and shared secrets without leaving the app. +
+ + {/* Table of contents */} +
+
+ Contents +
+
+ {SECTIONS.map((s) => ( + + {s.title} + + ))} +
+
+ + {SECTIONS.map((s) => ( +
+

+ {s.title} +

+
+ {s.body} +
+
+ ))} +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..8d08145 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { Theme } from "@/src/app/components/design-system"; +import "../user/dashboard/fonts.css"; +import { AdminShell } from "./AdminShell"; + +export const metadata: Metadata = { + title: "Admin", +}; + +// The legacy site navbar is mounted by the root layout; this layout only +// supplies the bkb Theme tokens that the admin pages use. +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/admin/page-access/page.tsx b/src/app/admin/page-access/page.tsx new file mode 100644 index 0000000..9c17088 --- /dev/null +++ b/src/app/admin/page-access/page.tsx @@ -0,0 +1,364 @@ +"use client"; + +/** + * /admin/page-access — manage which roles + users can see each UI page. + * + * Wired to: + * GET /api/admin/page-access + * PUT /api/admin/page-access/{page_key} + * DELETE /api/admin/page-access/{page_key} + * + * The UI then calls /api/access/page/{page_key} (via usePageAccess) to gate + * routes; this admin surface is what feeds those checks. + */ + +import React from "react"; +import { FONTS, Icon } from "@/src/app/components/design-system"; +import { + adminApi, + type PageAccess, + type AvailableRole, +} from "@/src/services/api/userManagement"; +import { TOOL_REGISTRY } from "@/src/config/toolRegistry"; + +export default function AdminPageAccessPage() { + const [pages, setPages] = React.useState([]); + const [roles, setRoles] = React.useState([]); + const [selectedKey, setSelectedKey] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [saving, setSaving] = React.useState(false); + + // Editor state + const [editKey, setEditKey] = React.useState(""); + const [editDesc, setEditDesc] = React.useState(""); + const [editPublic, setEditPublic] = React.useState(false); + const [editRoles, setEditRoles] = React.useState>(new Set()); + const [editEmails, setEditEmails] = React.useState(""); + + const reload = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [ps, rs] = await Promise.all([adminApi.listPageAccess(), adminApi.listRoles()]); + setPages(ps); + setRoles(rs); + } catch (e: any) { + setError(e?.message ?? "request failed"); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void reload(); + }, [reload]); + + // Hydrate editor when selection changes. + React.useEffect(() => { + if (!selectedKey) { + setEditKey(""); + setEditDesc(""); + setEditPublic(false); + setEditRoles(new Set()); + setEditEmails(""); + return; + } + const p = pages.find((x) => x.page_key === selectedKey); + if (!p) return; + setEditKey(p.page_key); + setEditDesc(p.description ?? ""); + setEditPublic(p.is_public); + setEditRoles(new Set(p.allowed_roles)); + setEditEmails(p.allowed_user_emails.join("\n")); + }, [selectedKey, pages]); + + function startNew() { + setSelectedKey(null); + setEditKey(""); + setEditDesc(""); + setEditPublic(false); + setEditRoles(new Set()); + setEditEmails(""); + } + + async function save() { + if (!editKey.trim()) return; + setSaving(true); + try { + const emails = editEmails + .split(/[\s,;]+/) + .map((s) => s.trim()) + .filter(Boolean); + await adminApi.upsertPageAccess(editKey.trim(), { + page_key: editKey.trim(), + description: editDesc.trim() || null, + is_public: editPublic, + allowed_roles: Array.from(editRoles), + allowed_user_emails: emails, + }); + setSelectedKey(editKey.trim()); + await reload(); + } catch (e: any) { + alert(`Could not save: ${e?.message ?? "request failed"}`); + } finally { + setSaving(false); + } + } + + async function deletePage(key: string) { + if (!confirm(`Delete page-access entry "${key}"? Routes that reference this key will fall back to "not_found".`)) return; + try { + await adminApi.deletePageAccess(key); + if (selectedKey === key) setSelectedKey(null); + await reload(); + } catch (e: any) { + alert(`Could not delete: ${e?.message ?? "request failed"}`); + } + } + + function toggleRole(name: string) { + setEditRoles((s) => { + const next = new Set(s); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + // Page-keys the UI knows about that the admin hasn't yet registered. These + // are shown as one-click stub-create buttons so getting started doesn't + // require typing keys by hand. + const knownKeys = new Set(pages.map((p) => p.page_key)); + // Skip admin-only tools — registering them in the page-access table is a + // no-op since the gate enforces admin via the `adminOnly` prop directly. + const seedables = TOOL_REGISTRY.filter((t) => !knownKeys.has(t.pageKey) && !t.adminOnly); + + function preFillFromTool(t: typeof TOOL_REGISTRY[number]) { + setSelectedKey(null); + setEditKey(t.pageKey); + setEditDesc(t.title); + setEditPublic(false); + setEditRoles(new Set()); + setEditEmails(""); + } + + return ( +
+
+
+

+ Page access +

+
+ Map UI page keys (e.g. admin.users) to allowed roles and user overrides. + Tools without an entry are denied by default. Admins always pass{" "} + regardless of the entries below — the gate short-circuits the role check, so deleting an{" "} + admin.* entry never locks an Admin out. +
+
+ +
+ + {!loading && seedables.length > 0 && ( +
+
+ {seedables.length} workflow tool{seedables.length === 1 ? "" : "s"} aren't registered yet — they're currently denied for everyone. +
+
+ Click one to pre-fill the editor, then assign roles or user emails and save. +
+
+ {seedables.map((t) => ( + + ))} +
+
+ )} + + {error && ( +
+
{error}
+
+ )} + +
+ {/* List */} +
+
+
Registered pages
+
+ {loading ? "Loading…" : `${pages.length} entries`} +
+
+
+ {pages.map((p) => { + const active = selectedKey === p.page_key; + return ( +
setSelectedKey(p.page_key)} + className="bkb-hover-row" + style={{ + padding: "10px 16px", + borderBottom: "1px solid var(--bkb-border)", + cursor: "pointer", + background: active ? "var(--bkb-surfaceAlt)" : "transparent", + display: "grid", + gridTemplateColumns: "1fr auto", + gap: 8, + alignItems: "center", + }} + > +
+
+ {p.page_key} +
+
+ {p.is_public ? "public" : `${p.allowed_roles.length} role(s) · ${p.allowed_user_emails.length} user(s)`} +
+
+ {(() => { + const isAdminPage = p.page_key.startsWith("admin."); + return ( + + ); + })()} +
+ ); + })} + {!loading && pages.length === 0 && ( +
+ No pages registered yet. +
+ )} +
+
+ + {/* Editor */} +
+
+ {selectedKey ? `Editing ${selectedKey}` : "Create a new page entry"} +
+ +
+
+ + setEditKey(e.target.value)} + disabled={!!selectedKey} + placeholder="e.g. admin.users" + /> +
+
+ + setEditDesc(e.target.value)} + placeholder="Optional" + /> +
+
+ + + +
+
Allowed roles
+
+ {roles.map((r) => { + const on = editRoles.has(r.name); + return ( + + ); + })} +
+
+ +
+
+ User-email overrides (one per line, comma, or space) +
+