mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: update authentication for the web app
This commit is contained in:
@@ -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`
|
||||||
@@ -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 <div className="py-12 text-center text-text-muted">Loading user management…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Administration</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-text-main">Users</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">Manage dashboard accounts and access roles.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={openCreate}>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">person_add</span>
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||||
|
|
||||||
|
<Card className="overflow-hidden p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-border-subtle bg-surface-2/50 text-xs uppercase tracking-wide text-text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 font-medium">Username</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Role</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Created</th>
|
||||||
|
<th className="px-5 py-3 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-subtle">
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan="5" className="px-5 py-12 text-center text-text-muted">Loading users…</td></tr>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<tr><td colSpan="5" className="px-5 py-12 text-center text-text-muted">No users found.</td></tr>
|
||||||
|
) : users.map((entry) => (
|
||||||
|
<tr key={entry.id} className="transition-colors hover:bg-surface-2/40">
|
||||||
|
<td className="px-5 py-4 font-medium text-text-main">{entry.username}{entry.id === user.id ? <span className="ml-2 text-xs font-normal text-text-muted">(you)</span> : null}</td>
|
||||||
|
<td className="px-5 py-4"><span className={`rounded-full px-2 py-1 text-xs font-medium ${entry.role === "admin" ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"}`}>{entry.role}</span></td>
|
||||||
|
<td className="px-5 py-4"><span className={entry.isActive ? "text-emerald-600 dark:text-emerald-400" : "text-text-muted"}>{entry.isActive ? "Active" : "Disabled"}</span></td>
|
||||||
|
<td className="px-5 py-4 text-text-muted">{formatDate(entry.createdAt)}</td>
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(entry)}>Edit</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700" onClick={() => setDeleteTarget(entry)} disabled={entry.id === user.id}>Delete</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!editor}
|
||||||
|
onClose={() => !saving && setEditor(null)}
|
||||||
|
title={editor?.mode === "create" ? "Add user" : `Edit ${editor?.user?.username || "user"}`}
|
||||||
|
footer={<><Button variant="ghost" onClick={() => setEditor(null)} disabled={saving}>Cancel</Button><Button variant="primary" type="submit" form="user-editor" loading={saving}>{editor?.mode === "create" ? "Create user" : "Save changes"}</Button></>}
|
||||||
|
>
|
||||||
|
<form id="user-editor" className="space-y-4" onSubmit={saveUser}>
|
||||||
|
<div className="space-y-2"><label className="text-sm font-medium">Username</label><Input value={form.username} onChange={(event) => setForm((current) => ({ ...current, username: event.target.value }))} minLength="3" required autoFocus /></div>
|
||||||
|
<div className="space-y-2"><label className="text-sm font-medium">{editor?.mode === "create" ? "Password" : "New password (optional)"}</label><Input type="password" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} minLength="6" required={editor?.mode === "create"} autoComplete="new-password" /></div>
|
||||||
|
<div className="space-y-2"><label className="text-sm font-medium">Role</label><select value={form.role} onChange={(event) => setForm((current) => ({ ...current, role: event.target.value }))} className="w-full rounded-lg border border-border-subtle bg-surface px-3 py-2 text-sm text-text-main"><option value="user">User</option><option value="admin">Administrator</option></select></div>
|
||||||
|
{editor?.mode === "edit" ? <label className="flex items-center gap-2 text-sm text-text-main"><input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} /> Account is active</label> : null}
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteTarget}
|
||||||
|
onClose={() => !saving && setDeleteTarget(null)}
|
||||||
|
onConfirm={deleteUser}
|
||||||
|
title="Delete user"
|
||||||
|
message={`Delete ${deleteTarget?.username || "this user"}? This cannot be undone.`}
|
||||||
|
confirmText="Delete user"
|
||||||
|
loading={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
|
||||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
|
||||||
import { isLocalRequest } from "@/dashboardGuard";
|
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 RESET_HINT = "Forgot password? Reset to default via 9Router CLI → Settings → Reset Password to Default.";
|
||||||
const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
|
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();
|
const settings = await getSettings();
|
||||||
|
|
||||||
// Block login via tunnel/tailscale if dashboard access is disabled
|
// 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 });
|
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)) {
|
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
|
||||||
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let isValid = false;
|
const user = await verifyUserCredentials(username, password);
|
||||||
if (storedHash) {
|
|
||||||
isValid = await bcrypt.compare(password, storedHash);
|
|
||||||
} else {
|
|
||||||
// Use env var or default
|
|
||||||
const initialPassword = process.env.INITIAL_PASSWORD || "123456";
|
|
||||||
isValid = password === initialPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValid) {
|
if (user) {
|
||||||
recordSuccess(ip);
|
recordSuccess(ip);
|
||||||
const cookieStore = await cookies();
|
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
|
// Default password still in use on a remote client → force a password
|
||||||
// change before the dashboard is exposed remotely (keeps local UX intact).
|
// change before the dashboard is exposed remotely (keeps local UX intact).
|
||||||
const mustChangePassword =
|
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);
|
const { remainingBeforeLock } = recordFail(ip);
|
||||||
@@ -74,7 +74,7 @@ export async function POST(request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
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 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
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.
|
// Reset the bootstrap administrator password. Local-only (enforced by dashboardGuard).
|
||||||
// Local-only (enforced by dashboardGuard). Never returns the default literal.
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
await updateSettings({ password: null });
|
await resetAdminPassword(process.env.INITIAL_PASSWORD || "123456");
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export async function GET() {
|
|||||||
const authMode = settings.authMode || "password";
|
const authMode = settings.authMode || "password";
|
||||||
const oidcName = String(session?.oidcName || "").trim();
|
const oidcName = String(session?.oidcName || "").trim();
|
||||||
const oidcEmail = String(session?.oidcEmail || "").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";
|
const loginMethod = session?.oidc ? "OIDC" : "Password";
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -21,9 +24,12 @@ export async function GET() {
|
|||||||
authMode,
|
authMode,
|
||||||
oidcConfigured: isOidcConfigured(settings),
|
oidcConfigured: isOidcConfigured(settings),
|
||||||
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
|
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
|
||||||
hasPassword: !!settings.password,
|
hasPassword: true,
|
||||||
displayName,
|
displayName,
|
||||||
loginMethod,
|
loginMethod,
|
||||||
|
userId: userId || null,
|
||||||
|
username: username || null,
|
||||||
|
role: session ? role : null,
|
||||||
oidcName: oidcName || null,
|
oidcName: oidcName || null,
|
||||||
oidcEmail: oidcEmail || null,
|
oidcEmail: oidcEmail || null,
|
||||||
oidcLogin: !!session?.oidc,
|
oidcLogin: !!session?.oidc,
|
||||||
@@ -37,6 +43,9 @@ export async function GET() {
|
|||||||
hasPassword: false,
|
hasPassword: false,
|
||||||
displayName: "Password user",
|
displayName: "Password user",
|
||||||
loginMethod: "Password",
|
loginMethod: "Password",
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
role: null,
|
||||||
oidcName: null,
|
oidcName: null,
|
||||||
oidcEmail: null,
|
oidcEmail: null,
|
||||||
oidcLogin: false,
|
oidcLogin: false,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { exportDb, getSettings, importDb } from "@/lib/localDb";
|
import { exportDb, getSettings, importDb } from "@/lib/localDb";
|
||||||
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
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 CLI_TOKEN_HEADER = "x-9r-cli-token";
|
||||||
const PASSWORD_HEADER = "x-9r-password";
|
const PASSWORD_HEADER = "x-9r-password";
|
||||||
@@ -13,7 +13,7 @@ function isCliRequest(request) {
|
|||||||
|
|
||||||
export async function GET(request) {
|
export async function GET(request) {
|
||||||
try {
|
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 });
|
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
const payload = await exportDb();
|
const payload = await exportDb();
|
||||||
@@ -27,7 +27,7 @@ export async function GET(request) {
|
|||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const { password, ...payload } = await request.json();
|
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 });
|
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
await importDb(payload);
|
await importDb(payload);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { getSettings, updateSettings } from "@/lib/localDb";
|
|||||||
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||||
import { resetComboRotation } from "open-sse/services/combo.js";
|
import { resetComboRotation } from "open-sse/services/combo.js";
|
||||||
import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing";
|
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 dynamic = "force-dynamic";
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@@ -45,28 +46,20 @@ export async function PATCH(request) {
|
|||||||
|
|
||||||
// If updating password, hash it
|
// If updating password, hash it
|
||||||
if (body.newPassword) {
|
if (body.newPassword) {
|
||||||
const settings = await getSettings();
|
let user;
|
||||||
const currentHash = settings.password;
|
try {
|
||||||
|
user = await requireCurrentDashboardUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
// Verify current password if it exists
|
|
||||||
if (currentHash) {
|
|
||||||
if (!body.currentPassword) {
|
if (!body.currentPassword) {
|
||||||
return NextResponse.json({ error: "Current password required" }, { status: 400 });
|
return NextResponse.json({ error: "Current password required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const isValid = await bcrypt.compare(body.currentPassword, currentHash);
|
if (!(await verifyUserPassword(user.id, body.currentPassword))) {
|
||||||
if (!isValid) {
|
|
||||||
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
|
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
} else {
|
await updateUser(user.id, { password: body.newPassword });
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const salt = await bcrypt.genSalt(10);
|
|
||||||
body.password = await bcrypt.hash(body.newPassword, salt);
|
|
||||||
delete body.newPassword;
|
delete body.newPassword;
|
||||||
delete body.currentPassword;
|
delete body.currentPassword;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-4
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { Card, Button, Input } from "@/shared/components";
|
import { Card, Button, Input } from "@/shared/components";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [resetHint, setResetHint] = useState("");
|
const [resetHint, setResetHint] = useState("");
|
||||||
@@ -67,7 +68,7 @@ export default function LoginPage() {
|
|||||||
const res = await fetch("/api/auth/login", {
|
const res = await fetch("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -143,7 +144,7 @@ export default function LoginPage() {
|
|||||||
<p className="text-text-muted">
|
<p className="text-text-muted">
|
||||||
{authMode === "oidc" && oidcConfigured
|
{authMode === "oidc" && oidcConfigured
|
||||||
? "Sign in with your OIDC provider to access the dashboard"
|
? "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"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,6 +194,19 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">Username</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus={!oidcAvailable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium">Password</label>
|
<label className="text-sm font-medium">Password</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -201,7 +215,7 @@ export default function LoginPage() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus={!oidcAvailable}
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
{retryAfter > 0 && (
|
{retryAfter > 0 && (
|
||||||
@@ -227,7 +241,7 @@ export default function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-center text-text-muted mt-2">
|
<p className="text-xs text-center text-text-muted mt-2">
|
||||||
Default password is <code className="bg-sidebar px-1 rounded">123456</code>
|
Default administrator login: <code className="bg-sidebar px-1 rounded">admin</code> / <code className="bg-sidebar px-1 rounded">123456</code>
|
||||||
</p>
|
</p>
|
||||||
{hasPassword === false && (
|
{hasPassword === false && (
|
||||||
<p className="text-xs text-center text-amber-600 dark:text-amber-400">
|
<p className="text-xs text-center text-amber-600 dark:text-amber-400">
|
||||||
|
|||||||
+31
-3
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { 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_HEADER = "x-9r-cli-token";
|
||||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||||
@@ -44,6 +44,10 @@ const ALWAYS_PROTECTED = [
|
|||||||
"/api/oauth/kiro/auto-import",
|
"/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
|
// Require auth, but allow through if requireLogin is disabled
|
||||||
const PROTECTED_API_PATHS = [
|
const PROTECTED_API_PATHS = [
|
||||||
"/api/settings",
|
"/api/settings",
|
||||||
@@ -148,7 +152,25 @@ async function canAccessLocalOnlyRoute(request) {
|
|||||||
|
|
||||||
async function hasValidToken(request) {
|
async function hasValidToken(request) {
|
||||||
const token = request.cookies.get("auth_token")?.value;
|
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
|
// Read settings directly from DB to avoid self-fetch deadlock in proxy
|
||||||
@@ -178,6 +200,7 @@ export const __test__ = {
|
|||||||
extractApiKey,
|
extractApiKey,
|
||||||
canAccessPublicLlmApi,
|
canAccessPublicLlmApi,
|
||||||
canAccessLocalOnlyRoute,
|
canAccessLocalOnlyRoute,
|
||||||
|
isAdmin,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function proxy(request) {
|
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 });
|
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.
|
// Deny-by-default for /api/* — public allow-list bypasses, everything else requires auth.
|
||||||
if (pathname.startsWith("/api/")) {
|
if (pathname.startsWith("/api/")) {
|
||||||
if (isPublicApi(pathname)) return NextResponse.next();
|
if (isPublicApi(pathname)) return NextResponse.next();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ export {
|
|||||||
getSettings, updateSettings, isCloudEnabled, getCloudUrl, exportSettings,
|
getSettings, updateSettings, isCloudEnabled, getCloudUrl, exportSettings,
|
||||||
} from "./repos/settingsRepo.js";
|
} from "./repos/settingsRepo.js";
|
||||||
|
|
||||||
|
// Users
|
||||||
|
export {
|
||||||
|
getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser,
|
||||||
|
countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword,
|
||||||
|
} from "./repos/usersRepo.js";
|
||||||
|
|
||||||
// Provider connections
|
// Provider connections
|
||||||
export {
|
export {
|
||||||
getProviderConnections, getProviderConnectionById,
|
getProviderConnections, getProviderConnectionById,
|
||||||
@@ -74,6 +80,7 @@ export async function exportDb() {
|
|||||||
|
|
||||||
const out = {
|
const out = {
|
||||||
settings: await exportSettings(),
|
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 })),
|
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 })),
|
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 })),
|
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)) {
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
throw new Error("Invalid database 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();
|
const db = await getAdapter();
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
// Wipe all tables (keep _meta)
|
// Wipe all tables (keep _meta)
|
||||||
db.run(`DELETE FROM settings`);
|
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 providerConnections`);
|
||||||
db.run(`DELETE FROM providerNodes`);
|
db.run(`DELETE FROM providerNodes`);
|
||||||
db.run(`DELETE FROM proxyPools`);
|
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)]);
|
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 || []) {
|
for (const c of payload.providerConnections || []) {
|
||||||
const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c;
|
const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c;
|
||||||
db.run(
|
db.run(
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
// Each migration: { version: number, name: string, up(db): void }
|
// Each migration: { version: number, name: string, up(db): void }
|
||||||
// Versions MUST be unique and monotonically increasing.
|
// Versions MUST be unique and monotonically increasing.
|
||||||
import m001 from "./001-initial.js";
|
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() {
|
export function latestVersion() {
|
||||||
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
+16
-1
@@ -3,7 +3,7 @@
|
|||||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
// 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.
|
// 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 = `
|
export const PRAGMA_SQL = `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
@@ -31,6 +31,21 @@ export const TABLES = {
|
|||||||
data: "TEXT NOT NULL",
|
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: {
|
providerConnections: {
|
||||||
columns: {
|
columns: {
|
||||||
id: "TEXT PRIMARY KEY",
|
id: "TEXT PRIMARY KEY",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Kept for backward compatibility with existing imports.
|
// Kept for backward compatibility with existing imports.
|
||||||
export {
|
export {
|
||||||
getSettings, updateSettings, isCloudEnabled, getCloudUrl,
|
getSettings, updateSettings, isCloudEnabled, getCloudUrl,
|
||||||
|
getUsers, getUserById, getUserByUsername, createUser, updateUser, deleteUser,
|
||||||
|
countActiveAdmins, verifyUserCredentials, verifyUserPassword, resetAdminPassword,
|
||||||
getProviderConnections, getProviderConnectionById,
|
getProviderConnections, getProviderConnectionById,
|
||||||
createProviderConnection, updateProviderConnection,
|
createProviderConnection, updateProviderConnection,
|
||||||
deleteProviderConnection, deleteProviderConnectionsByProvider,
|
deleteProviderConnection, deleteProviderConnectionsByProvider,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn";
|
|||||||
import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config";
|
import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config";
|
||||||
import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
|
import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
|
import useUserStore from "@/store/userStore";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import { ConfirmModal } from "./Modal";
|
import { ConfirmModal } from "./Modal";
|
||||||
import NineRemotePromoModal from "./NineRemotePromoModal";
|
import NineRemotePromoModal from "./NineRemotePromoModal";
|
||||||
@@ -49,6 +50,8 @@ export default function Sidebar({ onClose }) {
|
|||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [shutdownCountdown, setShutdownCountdown] = useState(0);
|
const [shutdownCountdown, setShutdownCountdown] = useState(0);
|
||||||
const [enableTranslator, setEnableTranslator] = useState(false);
|
const [enableTranslator, setEnableTranslator] = useState(false);
|
||||||
|
const user = useUserStore((state) => state.user);
|
||||||
|
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
|
||||||
const { copied, copy } = useCopyToClipboard(2000);
|
const { copied, copy } = useCopyToClipboard(2000);
|
||||||
|
|
||||||
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
|
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
|
||||||
@@ -60,6 +63,10 @@ export default function Sidebar({ onClose }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) fetchCurrentUser();
|
||||||
|
}, [fetchCurrentUser, user]);
|
||||||
|
|
||||||
// Lazy check for new npm version on mount
|
// Lazy check for new npm version on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/version")
|
fetch("/api/version")
|
||||||
@@ -263,6 +270,22 @@ export default function Sidebar({ onClose }) {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{user?.role === "admin" ? (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/users"
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-1 rounded-lg transition-all group",
|
||||||
|
isActive("/dashboard/users")
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">group</span>
|
||||||
|
<span className="text-[13px] font-medium">Users</span>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Debug items (inside System section, before Settings) */}
|
{/* Debug items (inside System section, before Settings) */}
|
||||||
{debugItems.map((item) => {
|
{debugItems.map((item) => {
|
||||||
const show = item.href !== "/dashboard/translator" || enableTranslator;
|
const show = item.href !== "/dashboard/translator" || enableTranslator;
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ const useUserStore = create((set) => ({
|
|||||||
setLoading: (loading) => set({ loading }),
|
setLoading: (loading) => set({ loading }),
|
||||||
|
|
||||||
setError: (error) => set({ error }),
|
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;
|
export default useUserStore;
|
||||||
|
|||||||
Reference in New Issue
Block a user