diff --git a/.github/instructions/project-structure.instructions.md b/.github/instructions/project-structure.instructions.md new file mode 100644 index 00000000..b12d68cf --- /dev/null +++ b/.github/instructions/project-structure.instructions.md @@ -0,0 +1,363 @@ +--- +description: Project structure, conventions, and coding guidelines for the 9Router codebase. Applies to all source code edits, reviews, and architectural decisions. +applyTo: '**/*' +--- + +# 9Router — Project Structure & Coding Guidelines + +## What This Is + +9Router (`9router-app`) is a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync. + +### Two Published Artifacts (One Repo) + +| Artifact | Location | npm name | Purpose | +|----------|----------|----------|---------| +| Dashboard + Gateway | root `package.json` | `9router-app` | Next.js server — actual routing engine | +| CLI Launcher | `cli/` | `9router` | Separate package — installs/starts server, manages tray | + +## Directory Map + +``` +9router/ +├── src/ # Next.js app + dashboard + compat APIs +│ ├── app/ +│ │ ├── api/ # Management + compatibility APIs +│ │ │ ├── v1/ # OpenAI-compatible endpoint (routed from /v1/* by next.config.mjs) +│ │ │ ├── v1beta/ # Gemini-compatible endpoint +│ │ │ ├── providers/ # Provider CRUD APIs +│ │ │ ├── auth/ # Authentication APIs (login, oauth, keys) +│ │ │ ├── models/ # Model listing API +│ │ │ ├── combos/ # Model combo CRUD +│ │ │ ├── oauth/ # OAuth flow handlers +│ │ │ ├── settings/ # Dashboard settings +│ │ │ ├── usage/ # Usage/statistics +│ │ │ ├── keys/ # API key management +│ │ │ ├── mcp/ # MCP integration +│ │ │ ├── pricing/ # Pricing data +│ │ │ ├── proxy-pools/ # Proxy pool management +│ │ │ ├── pxpipe/ # PXPipe token saver +│ │ │ ├── translator/ # Translator test endpoints +│ │ │ ├── tags/ # Tag management +│ │ │ ├── tunnel/ # Tunnel management +│ │ │ ├── health/ # Health check +│ │ │ └── version/ # Version info +│ │ ├── dashboard/ # Dashboard pages (Next.js pages router style) +│ │ ├── login/ # Login page +│ │ ├── landing/ # Landing page +│ │ ├── callback/ # OAuth callback handlers +│ │ ├── layout.js # Root layout +│ │ ├── page.js # Root page (redirects) +│ │ └── globals.css # Global styles +│ ├── lib/ +│ │ ├── db/ # SQLite persistence layer +│ │ │ ├── driver.js # Adapter fallback: bun:sqlite → better-sqlite3 → node:sqlite → sql.js +│ │ │ ├── adapters/ # Per-runtime SQLite adapters +│ │ │ ├── repos/ # Per-entity repos (connectionsRepo, combosRepo, settingsRepo, etc.) +│ │ │ ├── migrations/ # Schema migrations +│ │ │ ├── paths.js # DB file path resolver (DATA_DIR || ~/.9router/) +│ │ │ └── helpers/ # JSON column helpers, backups +│ │ ├── localDb.js # Backward-compat shim → re-exports @/lib/db/index.js +│ │ ├── usageDb.js # Usage + log persistence (~/.9router/usage.json, log.txt) +│ │ ├── disabledModelsDb.js # Disabled models DB +│ │ ├── requestDetailsDb.js # Request detail logging DB +│ │ ├── oauth/ # OAuth flow helpers +│ │ ├── headroom/ # Headroom token compression +│ │ ├── pxpipe/ # PXPipe multimodal compression +│ │ ├── qoder/ # Qoder provider helpers +│ │ ├── tunnel/ # Tunnel helpers +│ │ ├── network/ # Network utilities +│ │ ├── auth/ # Auth utilities +│ │ └── updater/ # App updater +│ ├── sse/ # App-side SSE glue (entry → open-sse engine) +│ │ ├── handlers/ # Chat handler (combo expansion, account selection) +│ │ ├── services/ # Token refresh, credential management +│ │ └── utils/ # SSE-specific utilities +│ ├── store/ # Zustand client stores +│ │ ├── index.js # Re-exports all stores +│ │ ├── providerStore.js # Provider state +│ │ ├── settingsStore.js # App settings +│ │ ├── userStore.js # User/auth state +│ │ ├── themeStore.js # Theme preferences +│ │ ├── notificationStore.js # Notification state +│ │ └── headerSearchStore.js # Header search state +│ ├── shared/ # Shared code (client + server) +│ │ ├── components/ # Reusable React components +│ │ ├── constants/ # Re-exported config from open-sse +│ │ ├── hooks/ # React hooks +│ │ ├── services/ # Shared API clients +│ │ └── utils/ # Shared utilities +│ ├── i18n/ # Runtime i18n (client-side JSON-based) +│ │ ├── config.js # Locale list + constants +│ │ ├── runtime.js # Client: load translation JSON, translate() function +│ │ └── RuntimeI18nProvider.js # React context provider +│ ├── dashboardGuard.js # Auth guard for dashboard routes +│ ├── proxy.js # Proxy middleware +│ └── models/ # Data models +├── open-sse/ # Provider-agnostic routing/translation ENGINE +│ ├── config/ # ALL constants — NEVER hardcode elsewhere +│ │ ├── providers.js # Provider definitions +│ │ ├── providerModels.js # Model alias → model matrix +│ │ ├── models.js # Model constants +│ │ ├── runtimeConfig.js # Timeouts, token limits, retry config +│ │ ├── appConstants.js # App-wide constants (endpoints, header builders) +│ │ └── *Constants.js # Provider-specific constants +│ ├── translator/ # Format conversion (client ↔ provider) +│ │ ├── index.js # Registry + translateRequest/translateResponse +│ │ ├── request/ # Request translators (e.g., openai-to-claude.js) +│ │ ├── response/ # Response translators +│ │ ├── schema/ # Enums: ROLE, CLAUDE_BLOCK, OPENAI_BLOCK +│ │ ├── concerns/ # Shared translation logic +│ │ ├── formats/ # Per-format helpers +│ │ └── formats.js # Format enum +│ ├── executors/ # Per-provider upstream HTTP calls +│ │ ├── base.js # BaseExecutor class +│ │ ├── default.js # DefaultExecutor (OpenAI-compatible providers) +│ │ ├── index.js # Executor registry map +│ │ └── {provider}.js # One file per non-standard provider +│ ├── handlers/ # Per-modality cores (chat, image, embedding, tts, stt, search) +│ │ ├── chatCore.js # Main chat handler entry +│ │ ├── chatCore/ # Streaming/non-streaming/SSE-to-JSON sub-handlers +│ │ ├── embedingsCore.js # Embedding handler +│ │ ├── imageGenerationCore.js # Image gen handler +│ │ ├── ttsCore.js # TTS handler +│ │ └── sttCore.js # STT handler +│ ├── providers/ # Provider registry + capabilities + pricing +│ │ ├── index.js # PROVIDERS export +│ │ ├── registry/ # One file per provider +│ │ ├── REGISTRY_TEMPLATE.js # Template for new providers +│ │ ├── capabilities.js # Model capability resolver +│ │ ├── pricing.js # Pricing data +│ │ └── shared.js # Shared provider constants +│ ├── rtk/ # Request Token Killer (pre-translate compression) +│ │ ├── index.js # tool_result content compressor +│ │ ├── headroom.js # External compress proxy +│ │ ├── caveman.js # System prompt injector +│ │ └── filters/ # Per-tool compressors + autodetect +│ ├── transformer/ # Response format transformers +│ ├── shared/ # Cross-provider auth/identity +│ ├── services/ # Model, provider, combo, account fallback, token refresh +│ └── utils/ # Stream handlers, SSE, error, proxy fetch, cloaking +├── tests/ # Independent ESM vitest package +│ ├── unit/ # Unit tests +│ ├── translator/ # Translator tests +│ ├── __baseline__/ # Regression baseline snapshots + known-fails +│ └── vitest.config.js # Test config (resolves @/ and open-sse aliases) +├── cli/ # CLI launcher (published separately as '9router') +│ ├── cli.js # CLI entry +│ ├── package.json # Independent version +│ ├── scripts/ # Build scripts +│ ├── src/cli/ # CLI source +│ └── hooks/ # npm hooks (postinstall, runtime detection) +├── docs/ARCHITECTURE.md # Full system architecture docs +├── open-sse/AGENTS.md # Engine-specific guide ("how to add X") +├── scripts/ # Registry migration + maintenance scripts +├── skills/ # AI skill definitions for 9Router +├── gitbook/ # GitBook documentation site (separate Next.js app) +├── images/ # Static images +├── public/ # Public assets +│ ├── i18n/literals/ # Translation JSON files +│ └── icons/ # App icons +└── i18n/ # Translated README files +``` + +## Coding Conventions + +### Language & Tooling + +- **Plain JavaScript (ESM)** — no TypeScript. Use JSDoc for type annotations where helpful. +- **Path aliases** (from `jsconfig.json`): + - `@/*` → `src/*` + - `open-sse` → `open-sse` + - `open-sse/*` → `open-sse/*` +- **Lint**: `eslint.config.mjs` extending `eslint-config-next` (core web vitals) +- **Commit style**: Conventional Commits — `feat(scope):`, `fix(scope):`, `chore(scope):` +- **Versioning**: Root and `cli/package.json` are versioned independently; log changes in `CHANGELOG.md` + +### Naming & Code Style + +- **camelCase** for variables, functions, methods +- **PascalCase** for classes (e.g., `BaseExecutor`, `DefaultExecutor`) +- **UPPER_SNAKE_CASE** for constants/enums (e.g., `ROLE`, `CLAUDE_BLOCK`, `FORMATS`) +- **Files**: kebab-case or camelCase as appropriate (e.g., `chatCore.js`, `providerModels.js`, `appConstants.js`) +- **No hardcoded strings** — use constants from `open-sse/config/` or `open-sse/translator/schema/` +- **Config-driven**: All provider/model/timeout/endpoint data lives in `open-sse/config/`, not scattered in code + +### Import Conventions + +```javascript +// From src/ code (Next.js app side): +import { getDb } from "@/lib/db"; +import { getProviderConnections } from "@/lib/localDb"; // backward-compat shim +import { getProviderConnections } from "@/lib/db/index.js"; // preferred for new code +import { loadTranslations } from "@/i18n/runtime"; +import { useProviderStore } from "@/store/providerStore"; + +// From open-sse/ code (engine side): +import { PROVIDERS } from "../providers/index.js"; +import { register } from "../translator/index.js"; +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { ROLE, CLAUDE_BLOCK } from "../translator/schema/index.js"; +``` + +### Client/Server Boundary + +Files in `src/app/` are Next.js app router pages and API routes. Client components must have `"use client"` directive at the top. See Zustand stores in `src/store/` for the client-side state pattern. + +## Architecture Rules + +### 1. `src/sse/` vs `open-sse/` Boundary + +- **`src/sse/`** — App-side entry glue: parses incoming requests, expands combos, selects accounts +- **`open-sse/`** — Provider-agnostic engine: translates formats, dispatches to executors, handles streaming + +Cross this boundary **consciously**. The engine (`open-sse/`) is designed to be usable standalone and should not import from `src/`. + +### 2. Request Flow + +``` +/v1/* request + → next.config.mjs rewrite (/v1/* → /api/v1/*) + → src/sse/handlers/chat.js (parse, combo expansion, account selection loop) + → open-sse/handlers/chatCore.js (detect source format, translate, dispatch) + → open-sse/executors/{provider}.js (per-provider upstream HTTP call) + → open-sse/translator/* (client format ↔ provider format) + → SSE stream back to client +``` + +### 3. Translator Pipeline + +- **OpenAI is the pivot format** — all translation routes through OpenAI as the intermediate format +- **Direct routes** are preferred for fragile pairs (thinking blocks, tool ids, non-base64 images, `is_error`): register on the exact `source:target` pair to skip the lossy double-hop +- **Self-registration**: Translators call `register(from, to, reqFn, resFn)` as an import side effect. A new translator file **MUST be imported in `open-sse/translator/index.js`** or it never runs +- Add request translators to `open-sse/translator/request/`, response translators to `open-sse/translator/response/` +- Reuse shared logic from `open-sse/translator/schema/` and `open-sse/translator/concerns/` + +### 4. Provider Registration + +- One file per provider in `open-sse/providers/registry/` +- `providers/registry/index.js` is **auto-generated** — regenerate with `scripts/migrate-registry.mjs`, don't hand-edit +- To add a provider: copy `REGISTRY_TEMPLATE.js`, add models to `config/providerModels.js` +- Only add an executor in `open-sse/executors/` for **non-OpenAI-compatible** upstreams + +### 5. Executor Pattern + +```javascript +// BaseExecutor defines the interface (open-sse/executors/base.js): +// getBaseUrls() → array of base URLs (for fallback) +// buildUrl(model, stream, urlIndex, credentials) +// buildHeaders(credentials, stream) +// transformRequest(model, body, stream, credentials) +// execute(model, body, stream, urlIndex, credentials, signal, ...) + +// For OpenAI-compatible providers — no custom executor needed (DefaultExecutor handles it) +// For non-standard providers — subclass BaseExecutor, override as needed +// Register in open-sse/executors/index.js map +``` + +### 6. Persistence (SQLite) + +- **State is in SQLite**, NOT `db.json` (ARCHITECTURE.md is stale on this point) +- Adapter fallback chain: `bun:sqlite` → `better-sqlite3` (optional dep) → `node:sqlite` (Node ≥22.5) → `sql.js` (pure-JS) +- `better-sqlite3` is in `optionalDependencies` — install never fails without build tools +- New code should import from `@/lib/db/index.js` +- Per-entity logic lives in `src/lib/db/repos/*` (e.g., `connectionsRepo.js`, `combosRepo.js`) +- Schema/migrations in `src/lib/db/migrations/` +- DB file location: `DATA_DIR` env var, else `~/.9router/` +- Usage/logs (`src/lib/usageDb.js`) live under `~/.9router` and do **not** follow `DATA_DIR` +- Repo pattern: each repo exports `getAll(db)`, `getById(db, id)`, `create(db, data)`, `update(db, id, data)`, `delete(db, id)`, `upsert(db, data)` using the `getAdapter()` from `driver.js` + +### 7. RTK (Request Token Killer) + +- Pre-translate hooks that compress `tool_result` content in-place to cut tokens +- **Fail-open**: any error returns null and leaves the body untouched — **never throw** out of them +- Skips `is_error`/`status:"error"` results to preserve traces +- Located in `open-sse/rtk/` + +### 8. Security Considerations + +- `custom-server.js` wraps Next standalone server to derive client IP from TCP socket and strip attacker-controlled `X-Forwarded-For` — preserve this when touching request/IP/rate-limit code +- Sensitive env vars: `JWT_SECRET`, `INITIAL_PASSWORD` (default `123456`), `API_KEY_SECRET`, `MACHINE_ID_SALT` +- Full env contract in `.env.example` + +## Key Design Patterns + +### Zustand Stores (Client State) + +```javascript +"use client"; +import { create } from "zustand"; +// Exported as default. Contains: state fields, setters, async fetch methods. +// fetchXxx() skips network when cache is fresh (< CLIENT_STORE_TTL_MS) +``` + +### I18n Pattern + +- Client-side runtime i18n (JSON files served from `/i18n/literals/{locale}.json`) +- Import `translate()` from `@/i18n/runtime` in any client component +- 35+ locales supported; English is the default (no translation JSON loaded) + +### API Route Pattern + +Next.js app router route handlers export HTTP method functions: + +```javascript +// src/app/api/some-endpoint/route.js +export async function GET(request) { ... } +export async function POST(request) { ... } +``` + +### React Components + +- Reusable components in `src/shared/components/` +- Use `"use client"` directive for interactive components +- Components are plain JSX functions (no TypeScript) +- Theming via CSS custom properties and `ThemeProvider` + +## Tests + +- **Vitest** ESM package in `tests/` — independent from root `npm test` +- Must `npm install` root deps first (tests import from `src/`) +- `vitest.config.js` resolves `@/` and `open-sse` aliases from repo root +- Not expected to be all-green: ~938 pass, ~64 fail on clean checkout +- Judge regressions with `tests/__baseline__/verify-no-regression.mjs` +- `*.real.test.js` make live provider calls — skip unless credentials are set + +## Commands Quick Reference + +```bash +# Dev (default port 20127, API at /v1, dashboard at /dashboard): +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev + +# Build + production: +npm run build && PORT=20128 HOSTNAME=0.0.0.0 npm run start + +# Bun variants: npm run dev:bun / build:bun / start:bun + +# Lint: npx eslint . + +# Test: cd tests && npx vitest run +# Single test: cd tests && npx vitest run unit/capabilities.test.js + +# CLI pack: npm run cli:pack +``` + +## Documentation + +Before making changes in these areas, read the authoritative docs: + +- `docs/ARCHITECTURE.md` — Full system architecture: request lifecycle, combo/account fallback, OAuth, cloud sync, data model +- `open-sse/AGENTS.md` — Engine conventions, how to add a provider/executor/translator +- `.env.example` — Full environment variable contract + +## Common Pitfalls + +1. Don't hand-edit `open-sse/providers/registry/index.js` — it's auto-generated +2. Don't forget to import new translators in `open-sse/translator/index.js` — they self-register as side effects +3. Don't hardcode role/block/model strings — use `open-sse/translator/schema/` and `open-sse/config/` +4. Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — handle in their own executor +5. RTK hooks must return null on error, never throw — they mutate in-place +6. `ARCHITECTURE.md` is stale on persistence (says `db.json`, reality is SQLite under `src/lib/db/`) +7. Usage/logs (`usage.json`, `log.txt`) do NOT follow `DATA_DIR` — they always live under `~/.9router/` +8. Tests need root `npm install` first before `cd tests && npm install` diff --git a/src/app/(dashboard)/dashboard/users/page.js b/src/app/(dashboard)/dashboard/users/page.js new file mode 100644 index 00000000..341ded68 --- /dev/null +++ b/src/app/(dashboard)/dashboard/users/page.js @@ -0,0 +1,196 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button, Card, Input } from "@/shared/components"; +import Modal, { ConfirmModal } from "@/shared/components/Modal"; +import useUserStore from "@/store/userStore"; + +const EMPTY_FORM = { username: "", password: "", role: "user", isActive: true }; + +function formatDate(value) { + if (!value) return "—"; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); +} + +export default function UsersPage() { + const router = useRouter(); + const user = useUserStore((state) => state.user); + const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [editor, setEditor] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [deleteTarget, setDeleteTarget] = useState(null); + + const loadUsers = useCallback(async () => { + setLoading(true); + setError(""); + try { + const response = await fetch("/api/users", { cache: "no-store" }); + if (response.status === 403) { + router.replace("/dashboard"); + return; + } + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Failed to load users"); + setUsers(data.users || []); + } catch (requestError) { + setError(requestError.message || "Failed to load users"); + } finally { + setLoading(false); + } + }, [router]); + + useEffect(() => { + if (!user) fetchCurrentUser(); + }, [fetchCurrentUser, user]); + + useEffect(() => { + if (!user) return undefined; + if (user.role !== "admin") { + router.replace("/dashboard"); + return undefined; + } + + const frameId = window.requestAnimationFrame(() => { void loadUsers(); }); + return () => window.cancelAnimationFrame(frameId); + }, [loadUsers, router, user]); + + const openCreate = () => { + setError(""); + setForm(EMPTY_FORM); + setEditor({ mode: "create" }); + }; + + const openEdit = (target) => { + setError(""); + setForm({ username: target.username, password: "", role: target.role, isActive: target.isActive }); + setEditor({ mode: "edit", user: target }); + }; + + const saveUser = async (event) => { + event.preventDefault(); + setSaving(true); + setError(""); + try { + const isCreate = editor.mode === "create"; + const payload = { ...form }; + if (!payload.password) delete payload.password; + const response = await fetch(isCreate ? "/api/users" : `/api/users/${editor.user.id}`, { + method: isCreate ? "POST" : "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Failed to save user"); + setEditor(null); + await loadUsers(); + } catch (requestError) { + setError(requestError.message || "Failed to save user"); + } finally { + setSaving(false); + } + }; + + const deleteUser = async () => { + if (!deleteTarget) return; + setSaving(true); + setError(""); + try { + const response = await fetch(`/api/users/${deleteTarget.id}`, { method: "DELETE" }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Failed to delete user"); + setDeleteTarget(null); + await loadUsers(); + } catch (requestError) { + setError(requestError.message || "Failed to delete user"); + } finally { + setSaving(false); + } + }; + + if (!user || user.role !== "admin") { + return
Loading user management…
; + } + + return ( +
+
+
+

Administration

+

Users

+

Manage dashboard accounts and access roles.

+
+ +
+ + {error ?

{error}

: null} + + +
+ + + + + + + + + + + + {loading ? ( + + ) : users.length === 0 ? ( + + ) : users.map((entry) => ( + + + + + + + + ))} + +
UsernameRoleStatusCreatedActions
Loading users…
No users found.
{entry.username}{entry.id === user.id ? (you) : null}{entry.role}{entry.isActive ? "Active" : "Disabled"}{formatDate(entry.createdAt)} +
+ + +
+
+
+
+ + !saving && setEditor(null)} + title={editor?.mode === "create" ? "Add user" : `Edit ${editor?.user?.username || "user"}`} + footer={<>} + > +
+
setForm((current) => ({ ...current, username: event.target.value }))} minLength="3" required autoFocus />
+
setForm((current) => ({ ...current, password: event.target.value }))} minLength="6" required={editor?.mode === "create"} autoComplete="new-password" />
+
+ {editor?.mode === "edit" ? : null} +
+
+ + !saving && setDeleteTarget(null)} + onConfirm={deleteUser} + title="Delete user" + message={`Delete ${deleteTarget?.username || "this user"}? This cannot be undone.`} + confirmText="Delete user" + loading={saving} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/api/auth/login/route.js b/src/app/api/auth/login/route.js index cbd3c6ab..b91558e5 100644 --- a/src/app/api/auth/login/route.js +++ b/src/app/api/auth/login/route.js @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { getSettings } from "@/lib/localDb"; -import bcrypt from "bcryptjs"; import { cookies } from "next/headers"; import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession"; import { isOidcConfigured } from "@/lib/auth/oidc"; import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter"; import { isLocalRequest } from "@/dashboardGuard"; +import { verifyUserCredentials } from "@/lib/db"; const RESET_HINT = "Forgot password? Reset to default via 9Router CLI → Settings → Reset Password to Default."; const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; @@ -28,7 +28,7 @@ export async function POST(request) { ); } - const { password } = await request.json(); + const { username, password } = await request.json(); const settings = await getSettings(); // Block login via tunnel/tailscale if dashboard access is disabled @@ -36,33 +36,33 @@ export async function POST(request) { return NextResponse.json({ error: "Dashboard access via tunnel is disabled" }, { status: 403 }); } - // Default password is '123456' if not set - const storedHash = settings.password; - if (settings.authMode === "oidc" && isOidcConfigured(settings)) { return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 }); } - let isValid = false; - if (storedHash) { - isValid = await bcrypt.compare(password, storedHash); - } else { - // Use env var or default - const initialPassword = process.env.INITIAL_PASSWORD || "123456"; - isValid = password === initialPassword; - } + const user = await verifyUserCredentials(username, password); - if (isValid) { + if (user) { recordSuccess(ip); const cookieStore = await cookies(); - await setDashboardAuthCookie(cookieStore, request); + await setDashboardAuthCookie(cookieStore, request, { + userId: user.id, + username: user.username, + role: user.role, + }); // Default password still in use on a remote client → force a password // change before the dashboard is exposed remotely (keeps local UX intact). const mustChangePassword = - !storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request); + user.username.toLowerCase() === "admin" && + !settings.password && + !process.env.INITIAL_PASSWORD && + !isLocalRequest(request); - return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS }); + return NextResponse.json( + { success: true, mustChangePassword, user: { id: user.id, username: user.username, role: user.role } }, + { headers: NO_STORE_HEADERS } + ); } const { remainingBeforeLock } = recordFail(ip); @@ -74,7 +74,7 @@ export async function POST(request) { ); } return NextResponse.json( - { error: `Invalid password. ${remainingBeforeLock} attempt(s) left before lockout.`, remainingBeforeLock }, + { error: `Invalid username or password. ${remainingBeforeLock} attempt(s) left before lockout.`, remainingBeforeLock }, { status: 401 } ); } catch (error) { diff --git a/src/app/api/auth/reset-password/route.js b/src/app/api/auth/reset-password/route.js index 49e6d724..bb6e396b 100644 --- a/src/app/api/auth/reset-password/route.js +++ b/src/app/api/auth/reset-password/route.js @@ -1,11 +1,10 @@ import { NextResponse } from "next/server"; -import { updateSettings } from "@/lib/localDb"; +import { resetAdminPassword } from "@/lib/db"; -// Reset dashboard password to default by clearing the stored hash. -// Local-only (enforced by dashboardGuard). Never returns the default literal. +// Reset the bootstrap administrator password. Local-only (enforced by dashboardGuard). export async function POST() { try { - await updateSettings({ password: null }); + await resetAdminPassword(process.env.INITIAL_PASSWORD || "123456"); return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json({ error: error.message }, { status: 500 }); diff --git a/src/app/api/auth/status/route.js b/src/app/api/auth/status/route.js index 32cc503b..350b487f 100644 --- a/src/app/api/auth/status/route.js +++ b/src/app/api/auth/status/route.js @@ -13,7 +13,10 @@ export async function GET() { const authMode = settings.authMode || "password"; const oidcName = String(session?.oidcName || "").trim(); const oidcEmail = String(session?.oidcEmail || "").trim(); - const displayName = oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user"); + const userId = String(session?.userId || "").trim(); + const username = String(session?.username || "").trim(); + const role = session?.role === "admin" ? "admin" : "user"; + const displayName = username || oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user"); const loginMethod = session?.oidc ? "OIDC" : "Password"; return NextResponse.json({ @@ -21,9 +24,12 @@ export async function GET() { authMode, oidcConfigured: isOidcConfigured(settings), oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC", - hasPassword: !!settings.password, + hasPassword: true, displayName, loginMethod, + userId: userId || null, + username: username || null, + role: session ? role : null, oidcName: oidcName || null, oidcEmail: oidcEmail || null, oidcLogin: !!session?.oidc, @@ -37,6 +43,9 @@ export async function GET() { hasPassword: false, displayName: "Password user", loginMethod: "Password", + userId: null, + username: null, + role: null, oidcName: null, oidcEmail: null, oidcLogin: false, diff --git a/src/app/api/settings/database/route.js b/src/app/api/settings/database/route.js index a20e27bf..893bf68f 100644 --- a/src/app/api/settings/database/route.js +++ b/src/app/api/settings/database/route.js @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { exportDb, getSettings, importDb } from "@/lib/localDb"; import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy"; -import { verifyDashboardPassword } from "@/lib/auth/dashboardSession"; +import { verifyCurrentDashboardUserPassword } from "@/lib/auth/currentUser"; const CLI_TOKEN_HEADER = "x-9r-cli-token"; const PASSWORD_HEADER = "x-9r-password"; @@ -13,7 +13,7 @@ function isCliRequest(request) { export async function GET(request) { try { - if (!isCliRequest(request) && !(await verifyDashboardPassword(request.headers.get(PASSWORD_HEADER)))) { + if (!isCliRequest(request) && !(await verifyCurrentDashboardUserPassword(request.headers.get(PASSWORD_HEADER)))) { return NextResponse.json({ error: "Invalid password" }, { status: 401 }); } const payload = await exportDb(); @@ -27,7 +27,7 @@ export async function GET(request) { export async function POST(request) { try { const { password, ...payload } = await request.json(); - if (!isCliRequest(request) && !(await verifyDashboardPassword(password))) { + if (!isCliRequest(request) && !(await verifyCurrentDashboardUserPassword(password))) { return NextResponse.json({ error: "Invalid password" }, { status: 401 }); } await importDb(payload); diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index ccbaee3a..49d2f4e7 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -3,7 +3,8 @@ import { getSettings, updateSettings } from "@/lib/localDb"; import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy"; import { resetComboRotation } from "open-sse/services/combo.js"; import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing"; -import bcrypt from "bcryptjs"; +import { requireCurrentDashboardUser } from "@/lib/auth/currentUser"; +import { updateUser, verifyUserPassword } from "@/lib/db"; export const dynamic = "force-dynamic"; export const revalidate = 0; @@ -45,28 +46,20 @@ export async function PATCH(request) { // If updating password, hash it if (body.newPassword) { - const settings = await getSettings(); - const currentHash = settings.password; - - // Verify current password if it exists - if (currentHash) { - if (!body.currentPassword) { - return NextResponse.json({ error: "Current password required" }, { status: 400 }); - } - const isValid = await bcrypt.compare(body.currentPassword, currentHash); - if (!isValid) { - return NextResponse.json({ error: "Invalid current password" }, { status: 401 }); - } - } else { - // First time setting password, no current password needed - // Allow empty currentPassword or default "123456" - if (body.currentPassword && body.currentPassword !== "123456") { - return NextResponse.json({ error: "Invalid current password" }, { status: 401 }); - } + let user; + try { + user = await requireCurrentDashboardUser(); + } catch { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const salt = await bcrypt.genSalt(10); - body.password = await bcrypt.hash(body.newPassword, salt); + if (!body.currentPassword) { + return NextResponse.json({ error: "Current password required" }, { status: 400 }); + } + if (!(await verifyUserPassword(user.id, body.currentPassword))) { + return NextResponse.json({ error: "Invalid current password" }, { status: 401 }); + } + await updateUser(user.id, { password: body.newPassword }); delete body.newPassword; delete body.currentPassword; } diff --git a/src/app/api/users/[userId]/route.js b/src/app/api/users/[userId]/route.js new file mode 100644 index 00000000..8a2e1e08 --- /dev/null +++ b/src/app/api/users/[userId]/route.js @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; +import { countActiveAdmins, deleteUser, getUserById, updateUser } from "@/lib/db"; +import { requireCurrentDashboardUser } from "@/lib/auth/currentUser"; + +const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; +const EDITABLE_FIELDS = new Set(["username", "password", "role", "isActive"]); + +function errorResponse(error) { + const message = error?.message || "Request failed"; + const status = message === "Unauthorized" ? 401 : message === "Forbidden" ? 403 : 400; + return NextResponse.json({ error: message }, { status, headers: NO_STORE_HEADERS }); +} + +async function getTarget(params) { + const { userId } = await params; + const target = await getUserById(userId); + if (!target) throw new Error("User not found"); + return target; +} + +function wouldRemoveLastActiveAdmin(target, updates, activeAdminCount) { + if (target.role !== "admin" || !target.isActive) return false; + const nextRole = Object.hasOwn(updates, "role") ? updates.role : target.role; + const nextActive = Object.hasOwn(updates, "isActive") ? updates.isActive === true : target.isActive; + return (nextRole !== "admin" || !nextActive) && activeAdminCount <= 1; +} + +export async function PATCH(request, { params }) { + try { + const actor = await requireCurrentDashboardUser(); + const target = await getTarget(params); + const body = await request.json(); + const updates = Object.fromEntries(Object.entries(body).filter(([key]) => EDITABLE_FIELDS.has(key))); + + if (actor.role !== "admin") { + const forbiddenChange = Object.hasOwn(updates, "username") || Object.hasOwn(updates, "role") || Object.hasOwn(updates, "isActive"); + if (actor.id !== target.id || forbiddenChange) throw new Error("Forbidden"); + } + + if ( + actor.id === target.id && + (Object.hasOwn(updates, "role") || Object.hasOwn(updates, "isActive")) + ) { + throw new Error("You cannot change your own role or account status"); + } + + if (wouldRemoveLastActiveAdmin(target, updates, await countActiveAdmins())) { + throw new Error("At least one active administrator is required"); + } + + const user = await updateUser(target.id, updates); + return NextResponse.json({ user }, { headers: NO_STORE_HEADERS }); + } catch (error) { + return errorResponse(error); + } +} + +export async function DELETE(request, { params }) { + try { + const actor = await requireCurrentDashboardUser(); + if (actor.role !== "admin") throw new Error("Forbidden"); + + const target = await getTarget(params); + if (target.id === actor.id) throw new Error("You cannot delete your own account"); + if (target.role === "admin" && target.isActive && await countActiveAdmins() <= 1) { + throw new Error("At least one active administrator is required"); + } + + await deleteUser(target.id); + return NextResponse.json({ success: true }, { headers: NO_STORE_HEADERS }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/src/app/api/users/route.js b/src/app/api/users/route.js new file mode 100644 index 00000000..2804a589 --- /dev/null +++ b/src/app/api/users/route.js @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { createUser, getUsers } from "@/lib/db"; +import { requireAdminUser } from "@/lib/auth/currentUser"; + +const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; + +function errorResponse(error) { + const message = error?.message || "Request failed"; + const status = message === "Unauthorized" ? 401 : message === "Forbidden" ? 403 : 400; + return NextResponse.json({ error: message }, { status, headers: NO_STORE_HEADERS }); +} + +export async function GET() { + try { + await requireAdminUser(); + return NextResponse.json({ users: await getUsers() }, { headers: NO_STORE_HEADERS }); + } catch (error) { + return errorResponse(error); + } +} + +export async function POST(request) { + try { + await requireAdminUser(); + const { username, password, role } = await request.json(); + const user = await createUser({ username, password, role }); + return NextResponse.json({ user }, { status: 201, headers: NO_STORE_HEADERS }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/src/app/login/page.js b/src/app/login/page.js index 8e50a191..ed97da06 100644 --- a/src/app/login/page.js +++ b/src/app/login/page.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, Input } from "@/shared/components"; export default function LoginPage() { + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [resetHint, setResetHint] = useState(""); @@ -67,7 +68,7 @@ export default function LoginPage() { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), + body: JSON.stringify({ username, password }), }); if (res.ok) { @@ -143,7 +144,7 @@ export default function LoginPage() {

{authMode === "oidc" && oidcConfigured ? "Sign in with your OIDC provider to access the dashboard" - : "Enter your password to access the dashboard"} + : "Enter your username and password to access the dashboard"}

@@ -193,6 +194,19 @@ export default function LoginPage() {

)} +
+ + setUsername(e.target.value)} + required + autoComplete="username" + autoFocus={!oidcAvailable} + /> +
+
setPassword(e.target.value)} required - autoFocus={!oidcAvailable} + autoComplete="current-password" /> {error &&

{error}

} {retryAfter > 0 && ( @@ -227,7 +241,7 @@ export default function LoginPage() {

- Default password is 123456 + Default administrator login: admin / 123456

{hasPassword === false && (

diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 3f90c4f8..4603b8af 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { getSettings, validateApiKey } from "@/lib/localDb"; +import { getSettings, getUserById, validateApiKey } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; -import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession"; +import { getDashboardAuthSession, verifyDashboardAuthToken } from "@/lib/auth/dashboardSession"; const CLI_TOKEN_HEADER = "x-9r-cli-token"; const CLI_TOKEN_SALT = "9r-cli-auth"; @@ -44,6 +44,10 @@ const ALWAYS_PROTECTED = [ "/api/oauth/kiro/auto-import", ]; +// User administration is never exposed to normal users, even if dashboard login +// is disabled for local single-user deployments. +const ADMIN_ONLY_PATHS = ["/api/users"]; + // Require auth, but allow through if requireLogin is disabled const PROTECTED_API_PATHS = [ "/api/settings", @@ -148,7 +152,25 @@ async function canAccessLocalOnlyRoute(request) { async function hasValidToken(request) { const token = request.cookies.get("auth_token")?.value; - return await verifyDashboardAuthToken(token); + if (!(await verifyDashboardAuthToken(token))) return false; + const session = await getDashboardAuthSession(token); + // Legacy/OIDC sessions do not have a local user record yet. Preserve their + // existing behavior; password sessions must reflect account deactivation. + if (!session?.userId) return true; + const user = await getUserById(String(session.userId)); + return !!user?.isActive; +} + +async function getAuthenticatedSession(request) { + const token = request.cookies.get("auth_token")?.value; + return getDashboardAuthSession(token); +} + +async function isAdmin(request) { + const session = await getAuthenticatedSession(request); + if (!session?.userId) return false; + const user = await getUserById(String(session.userId)); + return user?.isActive === true && user.role === "admin"; } // Read settings directly from DB to avoid self-fetch deadlock in proxy @@ -178,6 +200,7 @@ export const __test__ = { extractApiKey, canAccessPublicLlmApi, canAccessLocalOnlyRoute, + isAdmin, }; export async function proxy(request) { @@ -202,6 +225,11 @@ export async function proxy(request) { return NextResponse.json({ error: "API key required for remote API access" }, { status: 401 }); } + if (ADMIN_ONLY_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))) { + if (await hasValidCliToken(request) || await isAdmin(request)) return NextResponse.next(); + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } + // Deny-by-default for /api/* — public allow-list bypasses, everything else requires auth. if (pathname.startsWith("/api/")) { if (isPublicApi(pathname)) return NextResponse.next(); diff --git a/src/lib/auth/currentUser.js b/src/lib/auth/currentUser.js new file mode 100644 index 00000000..66ced082 --- /dev/null +++ b/src/lib/auth/currentUser.js @@ -0,0 +1,35 @@ +import { cookies } from "next/headers"; +import { getDashboardAuthSession } from "./dashboardSession.js"; +import { getUserById, verifyUserPassword } from "@/lib/db"; + +export async function getCurrentDashboardUser() { + const cookieStore = await cookies(); + const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value); + if (!session?.userId || !session?.username) return null; + const persistedUser = await getUserById(String(session.userId)); + if (!persistedUser || !persistedUser.isActive) return null; + + return { + id: persistedUser.id, + username: persistedUser.username, + role: persistedUser.role, + }; +} + +export async function requireCurrentDashboardUser() { + const user = await getCurrentDashboardUser(); + if (!user) throw new Error("Unauthorized"); + return user; +} + +export async function requireAdminUser() { + const user = await requireCurrentDashboardUser(); + if (user.role !== "admin") throw new Error("Forbidden"); + return user; +} + +export async function verifyCurrentDashboardUserPassword(password) { + const user = await getCurrentDashboardUser(); + if (!user) return false; + return verifyUserPassword(user.id, password); +} \ No newline at end of file diff --git a/src/lib/db/index.js b/src/lib/db/index.js index e1c68472..eac2a331 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -7,6 +7,12 @@ export { getSettings, updateSettings, isCloudEnabled, getCloudUrl, exportSettings, } from "./repos/settingsRepo.js"; +// Users +export { + getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser, + countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword, +} from "./repos/usersRepo.js"; + // Provider connections export { getProviderConnections, getProviderConnectionById, @@ -74,6 +80,7 @@ export async function exportDb() { const out = { settings: await exportSettings(), + users: db.all(`SELECT id, username, password, role, isActive, createdAt, updatedAt FROM users`).map((r) => ({ ...r, isActive: r.isActive === 1 || r.isActive === true })), providerConnections: db.all(`SELECT * FROM providerConnections`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, provider: r.provider, authType: r.authType, name: r.name, email: r.email, priority: r.priority, isActive: r.isActive === 1, createdAt: r.createdAt, updatedAt: r.updatedAt })), providerNodes: db.all(`SELECT * FROM providerNodes`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, type: r.type, name: r.name, createdAt: r.createdAt, updatedAt: r.updatedAt })), proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })), @@ -97,11 +104,23 @@ export async function importDb(payload) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new Error("Invalid database payload"); } + if (Array.isArray(payload.users)) { + const validUsers = payload.users.filter( + (user) => user?.id && user?.username && user?.password && ["admin", "user"].includes(user.role) + ); + if (validUsers.length !== payload.users.length) throw new Error("Invalid users data in database payload"); + if (!validUsers.some((user) => user.role === "admin" && user.isActive !== false)) { + throw new Error("Database import requires at least one active administrator"); + } + } const db = await getAdapter(); db.transaction(() => { // Wipe all tables (keep _meta) db.run(`DELETE FROM settings`); + // Old backups predate multi-user authentication. Preserve the local + // administrator unless the payload explicitly carries a users array. + if (Array.isArray(payload.users)) db.run(`DELETE FROM users`); db.run(`DELETE FROM providerConnections`); db.run(`DELETE FROM providerNodes`); db.run(`DELETE FROM proxyPools`); @@ -114,6 +133,16 @@ export async function importDb(payload) { db.run(`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`, [stringifyJson(payload.settings)]); } + if (Array.isArray(payload.users)) { + for (const user of payload.users) { + if (!user?.id || !user?.username || !user?.password || !["admin", "user"].includes(user.role)) continue; + db.run( + `INSERT OR REPLACE INTO users(id, username, password, role, isActive, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`, + [user.id, user.username, user.password, user.role, user.isActive === false ? 0 : 1, user.createdAt || new Date().toISOString(), user.updatedAt || new Date().toISOString()] + ); + } + } + for (const c of payload.providerConnections || []) { const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c; db.run( diff --git a/src/lib/db/migrations/002-users-table.js b/src/lib/db/migrations/002-users-table.js new file mode 100644 index 00000000..3128b2e5 --- /dev/null +++ b/src/lib/db/migrations/002-users-table.js @@ -0,0 +1,34 @@ +import bcrypt from "bcryptjs"; +import { v4 as uuidv4 } from "uuid"; +import { TABLES, buildCreateTableSql } from "../schema.js"; +import { parseJson } from "../helpers/jsonCol.js"; + +function getInitialAdminPasswordHash(db) { + const settingsRow = db.get(`SELECT data FROM settings WHERE id = 1`); + const settings = settingsRow ? parseJson(settingsRow.data, {}) : {}; + if (settings.password) return settings.password; + + const initialPassword = process.env.INITIAL_PASSWORD || "123456"; + return bcrypt.hashSync(initialPassword, 10); +} + +const migration = { + version: 2, + name: "users-table", + up(db) { + const users = TABLES.users; + db.exec(buildCreateTableSql("users", users)); + for (const index of users.indexes || []) db.exec(index); + + const existingAdmin = db.get(`SELECT id FROM users WHERE role = 'admin' LIMIT 1`); + if (existingAdmin) return; + + const timestamp = new Date().toISOString(); + db.run( + `INSERT INTO users(id, username, password, role, isActive, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`, + [uuidv4(), "admin", getInitialAdminPasswordHash(db), "admin", 1, timestamp, timestamp] + ); + }, +}; + +export default migration; \ No newline at end of file diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js index 13fb4bfe..24c32dbe 100644 --- a/src/lib/db/migrations/index.js +++ b/src/lib/db/migrations/index.js @@ -2,8 +2,9 @@ // Each migration: { version: number, name: string, up(db): void } // Versions MUST be unique and monotonically increasing. import m001 from "./001-initial.js"; +import m002 from "./002-users-table.js"; -export const MIGRATIONS = [m001].sort((a, b) => a.version - b.version); +export const MIGRATIONS = [m001, m002].sort((a, b) => a.version - b.version); export function latestVersion() { return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0; diff --git a/src/lib/db/repos/usersRepo.js b/src/lib/db/repos/usersRepo.js new file mode 100644 index 00000000..d788adda --- /dev/null +++ b/src/lib/db/repos/usersRepo.js @@ -0,0 +1,145 @@ +import bcrypt from "bcryptjs"; +import { v4 as uuidv4 } from "uuid"; +import { getAdapter } from "../driver.js"; + +const USER_ROLES = new Set(["admin", "user"]); + +function normalizeUsername(username) { + return typeof username === "string" ? username.trim() : ""; +} + +function rowToUser(row, includePassword = false) { + if (!row) return null; + const user = { + id: row.id, + username: row.username, + role: row.role, + isActive: row.isActive === 1 || row.isActive === true, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (includePassword) user.password = row.password; + return user; +} + +function assertRole(role) { + if (!USER_ROLES.has(role)) throw new Error("Invalid user role"); +} + +export async function getUsers() { + const db = await getAdapter(); + return db.all(`SELECT * FROM users ORDER BY createdAt ASC`).map((row) => rowToUser(row)); +} + +export async function getUserById(id, includePassword = false) { + const db = await getAdapter(); + return rowToUser(db.get(`SELECT * FROM users WHERE id = ?`, [id]), includePassword); +} + +export async function getUserByUsername(username, includePassword = false) { + const db = await getAdapter(); + return rowToUser( + db.get(`SELECT * FROM users WHERE username = ? COLLATE NOCASE`, [normalizeUsername(username)]), + includePassword + ); +} + +export async function createUser({ username, password, role = "user" }) { + const normalizedUsername = normalizeUsername(username); + if (normalizedUsername.length < 3) throw new Error("Username must be at least 3 characters"); + if (typeof password !== "string" || password.length < 6) throw new Error("Password must be at least 6 characters"); + assertRole(role); + + const db = await getAdapter(); + const duplicate = db.get(`SELECT id FROM users WHERE username = ? COLLATE NOCASE`, [normalizedUsername]); + if (duplicate) throw new Error("Username is already in use"); + + const timestamp = new Date().toISOString(); + const user = { + id: uuidv4(), + username: normalizedUsername, + password: await bcrypt.hash(password, 10), + role, + isActive: true, + createdAt: timestamp, + updatedAt: timestamp, + }; + db.run( + `INSERT INTO users(id, username, password, role, isActive, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?)`, + [user.id, user.username, user.password, user.role, 1, user.createdAt, user.updatedAt] + ); + return rowToUser(user); +} + +export async function updateUser(id, updates = {}) { + const db = await getAdapter(); + let result = null; + let passwordHash = null; + + if (Object.hasOwn(updates, "password")) { + if (typeof updates.password !== "string" || updates.password.length < 6) { + throw new Error("Password must be at least 6 characters"); + } + passwordHash = await bcrypt.hash(updates.password, 10); + } + + db.transaction(() => { + const row = db.get(`SELECT * FROM users WHERE id = ?`, [id]); + if (!row) return; + const current = rowToUser(row, true); + const username = Object.hasOwn(updates, "username") ? normalizeUsername(updates.username) : current.username; + const role = Object.hasOwn(updates, "role") ? updates.role : current.role; + const isActive = Object.hasOwn(updates, "isActive") ? updates.isActive === true : current.isActive; + let password = current.password; + + if (username.length < 3) throw new Error("Username must be at least 3 characters"); + assertRole(role); + if (passwordHash) password = passwordHash; + + const duplicate = db.get(`SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?`, [username, id]); + if (duplicate) throw new Error("Username is already in use"); + + const updatedAt = new Date().toISOString(); + db.run( + `UPDATE users SET username = ?, password = ?, role = ?, isActive = ?, updatedAt = ? WHERE id = ?`, + [username, password, role, isActive ? 1 : 0, updatedAt, id] + ); + result = { ...current, username, password, role, isActive, updatedAt }; + }); + + return rowToUser(result); +} + +export async function countActiveAdmins() { + const db = await getAdapter(); + return db.get(`SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND isActive = 1`)?.count || 0; +} + +export async function deleteUser(id) { + const db = await getAdapter(); + const result = db.run(`DELETE FROM users WHERE id = ?`, [id]); + return (result?.changes ?? 0) > 0; +} + +export async function verifyUserCredentials(username, password) { + if (typeof password !== "string" || !password) return null; + const user = await getUserByUsername(username, true); + if (!user || !user.isActive || !(await bcrypt.compare(password, user.password))) return null; + return rowToUser(user); +} + +export async function verifyUserPassword(id, password) { + if (typeof password !== "string" || !password) return false; + const user = await getUserById(id, true); + if (!user || !user.isActive) return false; + return bcrypt.compare(password, user.password); +} + +export async function resetAdminPassword(password) { + if (typeof password !== "string" || !password) throw new Error("Password is required"); + const db = await getAdapter(); + const admin = db.get(`SELECT id FROM users WHERE username = ? COLLATE NOCASE`, ["admin"]) + || db.get(`SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`); + if (!admin) throw new Error("No administrator account exists"); + return updateUser(admin.id, { password, isActive: true }); +} \ No newline at end of file diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index 099386c2..b9584e82 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -3,7 +3,7 @@ // pre-change safety backup in migrate.js: when the stored version is lower, // one lightweight DB backup is taken before applying schema changes. Forgetting // to bump only skips that backup — it does NOT break the additive auto-sync. -export const SCHEMA_VERSION = 1; +export const SCHEMA_VERSION = 2; export const PRAGMA_SQL = ` PRAGMA journal_mode = WAL; @@ -31,6 +31,21 @@ export const TABLES = { data: "TEXT NOT NULL", }, }, + users: { + columns: { + id: "TEXT PRIMARY KEY", + username: "TEXT UNIQUE NOT NULL", + password: "TEXT NOT NULL", + role: "TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user'))", + isActive: "INTEGER NOT NULL DEFAULT 1", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", + }, + indexes: [ + "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)", + "CREATE INDEX IF NOT EXISTS idx_users_role_active ON users(role, isActive)", + ], + }, providerConnections: { columns: { id: "TEXT PRIMARY KEY", diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 71d086e6..e9e1d915 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -2,6 +2,8 @@ // Kept for backward compatibility with existing imports. export { getSettings, updateSettings, isCloudEnabled, getCloudUrl, + getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser, + countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword, getProviderConnections, getProviderConnectionById, createProviderConnection, updateProviderConnection, deleteProviderConnection, deleteProviderConnectionsByProvider, diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 77211f72..069bf61d 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn"; import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config"; import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import useUserStore from "@/store/userStore"; import Button from "./Button"; import { ConfirmModal } from "./Modal"; import NineRemotePromoModal from "./NineRemotePromoModal"; @@ -49,6 +50,8 @@ export default function Sidebar({ onClose }) { const [isUpdating, setIsUpdating] = useState(false); const [shutdownCountdown, setShutdownCountdown] = useState(0); const [enableTranslator, setEnableTranslator] = useState(false); + const user = useUserStore((state) => state.user); + const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); const { copied, copy } = useCopyToClipboard(2000); const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest; @@ -60,6 +63,10 @@ export default function Sidebar({ onClose }) { .catch(() => {}); }, []); + useEffect(() => { + if (!user) fetchCurrentUser(); + }, [fetchCurrentUser, user]); + // Lazy check for new npm version on mount useEffect(() => { fetch("/api/version") @@ -263,6 +270,22 @@ export default function Sidebar({ onClose }) { ))} + {user?.role === "admin" ? ( + + group + Users + + ) : null} + {/* Debug items (inside System section, before Settings) */} {debugItems.map((item) => { const show = item.href !== "/dashboard/translator" || enableTranslator; diff --git a/src/store/userStore.js b/src/store/userStore.js index a82e1449..590d0497 100644 --- a/src/store/userStore.js +++ b/src/store/userStore.js @@ -14,6 +14,21 @@ const useUserStore = create((set) => ({ setLoading: (loading) => set({ loading }), setError: (error) => set({ error }), + + fetchCurrentUser: async () => { + set({ loading: true, error: null }); + try { + const response = await fetch("/api/auth/status", { cache: "no-store" }); + if (!response.ok) throw new Error("Failed to load current user"); + const data = await response.json(); + set({ + user: data.username ? { id: data.userId, username: data.username, role: data.role, displayName: data.displayName } : null, + loading: false, + }); + } catch (error) { + set({ user: null, error: error.message, loading: false }); + } + }, })); export default useUserStore;