feat: add optional auth with login, registration, and admin dashboard
Closes #227. Auth is disabled by default (AUTH_ENABLED=false) so localhost usage is unaffected. Set AUTH_ENABLED=true + NEXT_PUBLIC_AUTH_ENABLED=true to require login when hosting publicly. Backend - New deeptutor/services/auth.py: bcrypt password hashing, JWT create/decode, multi-user JSON store with role + created_at schema, auto-migration of old flat-hash format, first-user → admin bootstrap - New deeptutor/api/routers/auth.py: require_auth / require_admin FastAPI dependencies; public endpoints /login /logout /status /register /is_first_user; admin-only /users /users/{u}/role - deeptutor/api/main.py: Depends(require_auth) applied to all 14 protected routers - deeptutor/api/routers/unified_ws.py: cookie-based JWT check before ws.accept() when AUTH_ENABLED - Added bcrypt>=4.0.0 and python-jose[cryptography]>=3.3.0 to requirements/server.txt and pyproject.toml extras Frontend - web/middleware.ts: route protection; /login and /register are public - web/lib/api.ts: apiFetch wrapper — credentials:include + 401→login - web/lib/auth.ts: login/logout/fetchAuthStatus + register() + checkIsFirstUser() - web/lib/admin-api.ts: listUsers / deleteUser / setUserRole - web/lib/session-api.ts: credentials:include on all fetches; expectJson redirects to /login on 401 instead of throwing - web/app/(auth)/login/page.tsx: auto-redirects to /register when no users exist; shows success banner after registration - web/app/(auth)/register/page.tsx: new registration page with first-user admin notice and password confirmation - web/app/(admin)/admin/users/page.tsx: admin dashboard — user table with role toggle and delete; guards against self-demotion/deletion - AdminLink and LogoutButton hidden when AUTH_ENABLED=false - .env.example and README.md updated with auth vars and setup guide Made-with: Cursor
This commit is contained in:
@@ -67,3 +67,38 @@ NEXT_PUBLIC_API_BASE=
|
||||
# --------------------------------------------
|
||||
# Keep this false in production.
|
||||
DISABLE_SSL_VERIFY=false
|
||||
|
||||
# --------------------------------------------
|
||||
# Authentication (Optional)
|
||||
# --------------------------------------------
|
||||
# Set AUTH_ENABLED=true to require login when hosting publicly.
|
||||
# Leave as false for local/localhost usage — no login needed.
|
||||
AUTH_ENABLED=false
|
||||
|
||||
# Secret key used to sign JWT tokens. Set to a long random string.
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
AUTH_SECRET=
|
||||
|
||||
# How long login sessions last in hours (default: 24)
|
||||
AUTH_TOKEN_EXPIRE_HOURS=24
|
||||
|
||||
# Must match AUTH_ENABLED above so the frontend knows to show the login page.
|
||||
NEXT_PUBLIC_AUTH_ENABLED=false
|
||||
|
||||
# --- Multi-user setup (recommended) ---
|
||||
# With AUTH_ENABLED=true and no AUTH_USERNAME/AUTH_PASSWORD_HASH set, navigate
|
||||
# to /register in the browser. The first user to register is automatically
|
||||
# granted admin privileges and can manage other users from /admin/users.
|
||||
# Users are stored in data/user/auth_users.json.
|
||||
|
||||
# --- Single-user env-var setup (legacy / simple) ---
|
||||
# Alternatively, set AUTH_USERNAME and AUTH_PASSWORD_HASH directly.
|
||||
# This user is always treated as admin. When auth_users.json exists, it takes
|
||||
# priority and these env vars are ignored.
|
||||
|
||||
# Username for the single admin account (single-user mode only).
|
||||
AUTH_USERNAME=
|
||||
|
||||
# Bcrypt hash of the password. Generate with:
|
||||
# python -c "from deeptutor.services.auth import hash_password; print(hash_password('yourpassword'))"
|
||||
AUTH_PASSWORD_HASH=
|
||||
|
||||
+229
-19
@@ -90,10 +90,6 @@
|
||||
{
|
||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
||||
"filename": ".secrets.baseline"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||
"min_level": 2
|
||||
@@ -127,41 +123,255 @@
|
||||
}
|
||||
],
|
||||
"results": {
|
||||
".github/workflows/docker-publish.yml": [
|
||||
".env.example_CN": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": ".github/workflows/docker-publish.yml",
|
||||
"hashed_secret": "a7c6f2bf2a1f9d8b6cd1b1c8d550b25cf6c1f970",
|
||||
"filename": ".env.example_CN",
|
||||
"hashed_secret": "ec417f567082612f8fd6afafe1abcab831fca840",
|
||||
"is_verified": false,
|
||||
"line_number": 175
|
||||
"line_number": 19
|
||||
}
|
||||
],
|
||||
"web/app/guide/page.tsx": [
|
||||
"deeptutor/agents/guide/agents/interactive_agent.py": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "web/app/guide/page.tsx",
|
||||
"filename": "deeptutor/agents/guide/agents/interactive_agent.py",
|
||||
"hashed_secret": "559f33e318ea8360e316ec7409de63272a7daea0",
|
||||
"is_verified": false,
|
||||
"line_number": 159,
|
||||
"is_secret": false
|
||||
"line_number": 88
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "web/app/guide/page.tsx",
|
||||
"filename": "deeptutor/agents/guide/agents/interactive_agent.py",
|
||||
"hashed_secret": "fe9ae166dc80168f37e50564be9e45c016a48cd0",
|
||||
"is_verified": false,
|
||||
"line_number": 161,
|
||||
"is_secret": false
|
||||
"line_number": 89
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "web/app/guide/page.tsx",
|
||||
"filename": "deeptutor/agents/guide/agents/interactive_agent.py",
|
||||
"hashed_secret": "482f1d4d250823ce0ff5fad9dfaf0d471835aedb",
|
||||
"is_verified": false,
|
||||
"line_number": 163,
|
||||
"is_secret": false
|
||||
"line_number": 90
|
||||
}
|
||||
],
|
||||
"deeptutor/agents/solve/main_solver.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "deeptutor/agents/solve/main_solver.py",
|
||||
"hashed_secret": "985b05576c57bdeccfcaf33261ea1c43ce7e3155",
|
||||
"is_verified": false,
|
||||
"line_number": 184
|
||||
}
|
||||
],
|
||||
"deeptutor/api/routers/system.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "deeptutor/api/routers/system.py",
|
||||
"hashed_secret": "985b05576c57bdeccfcaf33261ea1c43ce7e3155",
|
||||
"is_verified": false,
|
||||
"line_number": 160
|
||||
}
|
||||
],
|
||||
"deeptutor/services/config/provider_runtime.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "deeptutor/services/config/provider_runtime.py",
|
||||
"hashed_secret": "985b05576c57bdeccfcaf33261ea1c43ce7e3155",
|
||||
"is_verified": false,
|
||||
"line_number": 331
|
||||
}
|
||||
],
|
||||
"deeptutor/services/search/base.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "deeptutor/services/search/base.py",
|
||||
"hashed_secret": "8ee9d3cc35862a8ad26f9e27fb60db0e22ffe145",
|
||||
"is_verified": false,
|
||||
"line_number": 18
|
||||
}
|
||||
],
|
||||
"tests/agents/test_base_agent_binding.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/agents/test_base_agent_binding.py",
|
||||
"hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd",
|
||||
"is_verified": false,
|
||||
"line_number": 18
|
||||
}
|
||||
],
|
||||
"tests/core/test_builtin_tools.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/core/test_builtin_tools.py",
|
||||
"hashed_secret": "a62f2225bf70bfaccbc7f1ef2a397836717377de",
|
||||
"is_verified": false,
|
||||
"line_number": 188
|
||||
}
|
||||
],
|
||||
"tests/services/config/test_embedding_runtime.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/config/test_embedding_runtime.py",
|
||||
"hashed_secret": "9aaa910eb49fa1f278c7288f0fa6001c16965716",
|
||||
"is_verified": false,
|
||||
"line_number": 68
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/config/test_embedding_runtime.py",
|
||||
"hashed_secret": "985b05576c57bdeccfcaf33261ea1c43ce7e3155",
|
||||
"is_verified": false,
|
||||
"line_number": 134
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/config/test_embedding_runtime.py",
|
||||
"hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd",
|
||||
"is_verified": false,
|
||||
"line_number": 144
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/config/test_embedding_runtime.py",
|
||||
"hashed_secret": "8c895e1fc99e38655efea259aaa5510bde46b81c",
|
||||
"is_verified": false,
|
||||
"line_number": 182
|
||||
}
|
||||
],
|
||||
"tests/services/config/test_provider_runtime.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/config/test_provider_runtime.py",
|
||||
"hashed_secret": "b07bfd6660c8a5e8f47ed4967498d25f095cb7c5",
|
||||
"is_verified": false,
|
||||
"line_number": 95
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/config/test_provider_runtime.py",
|
||||
"hashed_secret": "1e8f4accfda4813178f4f6317c767a915bfbf227",
|
||||
"is_verified": false,
|
||||
"line_number": 115
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/config/test_provider_runtime.py",
|
||||
"hashed_secret": "985b05576c57bdeccfcaf33261ea1c43ce7e3155",
|
||||
"is_verified": false,
|
||||
"line_number": 163
|
||||
}
|
||||
],
|
||||
"tests/services/embedding/test_client_runtime.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/embedding/test_client_runtime.py",
|
||||
"hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd",
|
||||
"is_verified": false,
|
||||
"line_number": 35
|
||||
}
|
||||
],
|
||||
"tests/services/llm/test_config_module.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/llm/test_config_module.py",
|
||||
"hashed_secret": "6b8f36db289d14c3c7ba1bb21147b7a4fa7e7536",
|
||||
"is_verified": false,
|
||||
"line_number": 40
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/llm/test_config_module.py",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 102
|
||||
}
|
||||
],
|
||||
"tests/services/llm/test_factory_provider_exec.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/llm/test_factory_provider_exec.py",
|
||||
"hashed_secret": "6b8f36db289d14c3c7ba1bb21147b7a4fa7e7536",
|
||||
"is_verified": false,
|
||||
"line_number": 15
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/llm/test_factory_provider_exec.py",
|
||||
"hashed_secret": "9247e92e3d5c3957c5f5480ec8917b9100baa32d",
|
||||
"is_verified": false,
|
||||
"line_number": 41
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/llm/test_factory_provider_exec.py",
|
||||
"hashed_secret": "627a27ae52d38269376e0117355fefc891b60334",
|
||||
"is_verified": false,
|
||||
"line_number": 84
|
||||
}
|
||||
],
|
||||
"tests/services/test_model_catalog.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/test_model_catalog.py",
|
||||
"hashed_secret": "f2bb1f20475e99c2af44a6f6844c1329bea9d3d2",
|
||||
"is_verified": false,
|
||||
"line_number": 89
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/test_model_catalog.py",
|
||||
"hashed_secret": "21729167f484c69c93d2a1956dace832faee1e6a",
|
||||
"is_verified": false,
|
||||
"line_number": 107
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/test_model_catalog.py",
|
||||
"hashed_secret": "3090ca86558e326f27cb6f36d0592ed339f108be",
|
||||
"is_verified": false,
|
||||
"line_number": 141
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/services/test_model_catalog.py",
|
||||
"hashed_secret": "c02d46771666140734db6f2e650f2b051f448aad",
|
||||
"is_verified": false,
|
||||
"line_number": 146
|
||||
}
|
||||
],
|
||||
"tests/test_openrouter_provider.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/test_openrouter_provider.py",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 10
|
||||
}
|
||||
],
|
||||
"web/app/(workspace)/guide/hooks/useKaTeXInjection.ts": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "web/app/(workspace)/guide/hooks/useKaTeXInjection.ts",
|
||||
"hashed_secret": "559f33e318ea8360e316ec7409de63272a7daea0",
|
||||
"is_verified": false,
|
||||
"line_number": 25
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "web/app/(workspace)/guide/hooks/useKaTeXInjection.ts",
|
||||
"hashed_secret": "fe9ae166dc80168f37e50564be9e45c016a48cd0",
|
||||
"is_verified": false,
|
||||
"line_number": 27
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "web/app/(workspace)/guide/hooks/useKaTeXInjection.ts",
|
||||
"hashed_secret": "482f1d4d250823ce0ff5fad9dfaf0d471835aedb",
|
||||
"is_verified": false,
|
||||
"line_number": 29
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-01-14T02:31:46Z"
|
||||
"generated_at": "2026-04-06T16:37:14Z"
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
|
||||
### 📰 News
|
||||
|
||||
> **[2026.4.4]** Long time no see! ✨ DeepTutor v1.0.0 is finally here — an agent-native evolution featuring a ground-up architecture rewrite, TutorBot, and flexible mode switching under the Apache-2.0 license. A new chapter begins, and our story continues!
|
||||
> **[2026.4.4]** Long time no see! ✨ DeepTutor v1.0.0 is finally here — an agent-native evolution featuring a ground-up architecture rewrite, TutorBot, and flexible mode switching under the Apache-2.0 license. A new chapter begins, and our story continues!
|
||||
|
||||
> **[2026.2.6]** 🚀 We've reached 10k stars in just 39 days! A huge thank you to our incredible community for the support!
|
||||
> **[2026.2.6]** 🚀 We've reached 10k stars in just 39 days! A huge thank you to our incredible community for the support!
|
||||
|
||||
> **[2026.1.1]** Happy New Year! Join our [Discord](https://discord.gg/eRsjPgMU4t), [WeChat](https://github.com/HKUDS/DeepTutor/issues/78), or [Discussions](https://github.com/HKUDS/DeepTutor/discussions) — let's shape the future of DeepTutor together!
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
- **Knowledge Hub** — Upload PDFs, Markdown, and text files to build RAG-ready knowledge bases. Organize insights across sessions in color-coded notebooks. Your documents don't just sit there — they actively power every conversation.
|
||||
- **Persistent Memory** — DeepTutor builds a living profile of you: what you've studied, how you learn, and where you're heading. Shared across all features and TutorBots, it gets sharper with every interaction.
|
||||
- **Agent-Native CLI** — Every capability, knowledge base, session, and TutorBot is one command away. Rich terminal output for humans, structured JSON for AI agents and pipelines. Hand DeepTutor a [`SKILL.md`](SKILL.md) and your agents can operate it autonomously.
|
||||
- **Optional Authentication** — Disabled by default for local use. Flip two env vars to require login when hosting publicly. Multi-user support with bcrypt-hashed passwords, JWT sessions, a self-service registration page, and a built-in admin dashboard for managing accounts and roles.
|
||||
|
||||
---
|
||||
|
||||
@@ -293,6 +294,48 @@ The frontend startup script applies this value at runtime — no rebuild needed.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Authentication (public deployments)</b></summary>
|
||||
|
||||
Authentication is **disabled by default** — no login is required on localhost. To protect a publicly accessible instance, add these variables to your `.env`:
|
||||
|
||||
```dotenv
|
||||
# Enable login (set both to the same value)
|
||||
AUTH_ENABLED=true
|
||||
NEXT_PUBLIC_AUTH_ENABLED=true
|
||||
|
||||
# Long random secret for signing JWT tokens
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
AUTH_SECRET=your-secret-here
|
||||
|
||||
# How long sessions last in hours (default: 24)
|
||||
AUTH_TOKEN_EXPIRE_HOURS=24
|
||||
```
|
||||
|
||||
**First-time setup (multi-user):**
|
||||
|
||||
1. Leave `AUTH_USERNAME` and `AUTH_PASSWORD_HASH` unset.
|
||||
2. Open your DeepTutor URL — you will be redirected to `/register`.
|
||||
3. The first user to register is automatically granted **admin** privileges.
|
||||
4. Admins can manage all accounts at `/admin/users` (promote, demote, delete).
|
||||
|
||||
**Single-user setup (env-var):**
|
||||
|
||||
```bash
|
||||
# Generate a password hash
|
||||
python -c "from deeptutor.services.auth import hash_password; print(hash_password('yourpassword'))"
|
||||
```
|
||||
|
||||
```dotenv
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD_HASH=<paste hash here>
|
||||
```
|
||||
|
||||
Users are stored in `data/user/auth_users.json`. Once that file exists it takes
|
||||
priority over `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Development mode (hot-reload)</b></summary>
|
||||
|
||||
@@ -358,6 +401,12 @@ These directories survive `docker compose down` and are reused on the next `dock
|
||||
| `FRONTEND_PORT` | No | Frontend port (default `3782`) |
|
||||
| `NEXT_PUBLIC_API_BASE_EXTERNAL` | No | Public backend URL for cloud deployment |
|
||||
| `DISABLE_SSL_VERIFY` | No | Disable SSL verification (default `false`) |
|
||||
| `AUTH_ENABLED` | No | Require login when `true` (default `false`) |
|
||||
| `NEXT_PUBLIC_AUTH_ENABLED` | No | Must match `AUTH_ENABLED` — controls frontend auth UI |
|
||||
| `AUTH_SECRET` | No* | JWT signing secret — required when `AUTH_ENABLED=true` |
|
||||
| `AUTH_TOKEN_EXPIRE_HOURS` | No | Session duration in hours (default `24`) |
|
||||
| `AUTH_USERNAME` | No | Single-user mode: admin username |
|
||||
| `AUTH_PASSWORD_HASH` | No | Single-user mode: bcrypt hash of admin password |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+54
-24
@@ -1,9 +1,7 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -107,6 +105,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
try:
|
||||
from deeptutor.services.tutorbot import get_tutorbot_manager
|
||||
|
||||
await get_tutorbot_manager().auto_start_bots()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to auto-start TutorBots: {e}")
|
||||
@@ -119,6 +118,7 @@ async def lifespan(app: FastAPI):
|
||||
# Stop TutorBots
|
||||
try:
|
||||
from deeptutor.services.tutorbot import get_tutorbot_manager
|
||||
|
||||
await get_tutorbot_manager().stop_all()
|
||||
logger.info("TutorBots stopped")
|
||||
except Exception as e:
|
||||
@@ -198,6 +198,7 @@ app.mount(
|
||||
# Some router modules load YAML settings at import time.
|
||||
from deeptutor.api.routers import (
|
||||
agent_config,
|
||||
auth,
|
||||
chat,
|
||||
co_writer,
|
||||
dashboard,
|
||||
@@ -214,29 +215,58 @@ from deeptutor.api.routers import (
|
||||
tutorbot,
|
||||
unified_ws,
|
||||
vision_solver,
|
||||
question_notebook,
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(solve.router, prefix="/api/v1", tags=["solve"])
|
||||
app.include_router(chat.router, prefix="/api/v1", tags=["chat"])
|
||||
app.include_router(question.router, prefix="/api/v1/question", tags=["question"])
|
||||
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||
app.include_router(dashboard.router, prefix="/api/v1/dashboard", tags=["dashboard"])
|
||||
app.include_router(co_writer.router, prefix="/api/v1/co_writer", tags=["co_writer"])
|
||||
app.include_router(notebook.router, prefix="/api/v1/notebook", tags=["notebook"])
|
||||
app.include_router(guide.router, prefix="/api/v1/guide", tags=["guide"])
|
||||
app.include_router(memory.router, prefix="/api/v1/memory", tags=["memory"])
|
||||
app.include_router(sessions.router, prefix="/api/v1/sessions", tags=["sessions"])
|
||||
app.include_router(question_notebook.router, prefix="/api/v1/question-notebook", tags=["question-notebook"])
|
||||
app.include_router(settings.router, prefix="/api/v1/settings", tags=["settings"])
|
||||
app.include_router(system.router, prefix="/api/v1/system", tags=["system"])
|
||||
app.include_router(plugins_api.router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
app.include_router(agent_config.router, prefix="/api/v1/agent-config", tags=["agent-config"])
|
||||
app.include_router(vision_solver.router, prefix="/api/v1", tags=["vision-solver"])
|
||||
app.include_router(tutorbot.router, prefix="/api/v1/tutorbot", tags=["tutorbot"])
|
||||
# Auth router is public — login/logout/register/status require no token
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# Unified WebSocket endpoint
|
||||
# All other routers require a valid session when AUTH_ENABLED=true.
|
||||
# require_auth is a no-op when AUTH_ENABLED=false, so this is safe for local use.
|
||||
from deeptutor.api.routers.auth import require_auth # noqa: E402
|
||||
|
||||
_auth = [Depends(require_auth)]
|
||||
|
||||
app.include_router(solve.router, prefix="/api/v1", tags=["solve"], dependencies=_auth)
|
||||
app.include_router(chat.router, prefix="/api/v1", tags=["chat"], dependencies=_auth)
|
||||
app.include_router(
|
||||
question.router, prefix="/api/v1/question", tags=["question"], dependencies=_auth
|
||||
)
|
||||
app.include_router(
|
||||
knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"], dependencies=_auth
|
||||
)
|
||||
app.include_router(
|
||||
dashboard.router, prefix="/api/v1/dashboard", tags=["dashboard"], dependencies=_auth
|
||||
)
|
||||
app.include_router(
|
||||
co_writer.router, prefix="/api/v1/co_writer", tags=["co_writer"], dependencies=_auth
|
||||
)
|
||||
app.include_router(
|
||||
notebook.router, prefix="/api/v1/notebook", tags=["notebook"], dependencies=_auth
|
||||
)
|
||||
app.include_router(guide.router, prefix="/api/v1/guide", tags=["guide"], dependencies=_auth)
|
||||
app.include_router(memory.router, prefix="/api/v1/memory", tags=["memory"], dependencies=_auth)
|
||||
app.include_router(
|
||||
sessions.router, prefix="/api/v1/sessions", tags=["sessions"], dependencies=_auth
|
||||
)
|
||||
app.include_router(
|
||||
settings.router, prefix="/api/v1/settings", tags=["settings"], dependencies=_auth
|
||||
)
|
||||
app.include_router(system.router, prefix="/api/v1/system", tags=["system"], dependencies=_auth)
|
||||
app.include_router(
|
||||
plugins_api.router, prefix="/api/v1/plugins", tags=["plugins"], dependencies=_auth
|
||||
)
|
||||
app.include_router(
|
||||
agent_config.router, prefix="/api/v1/agent-config", tags=["agent-config"], dependencies=_auth
|
||||
)
|
||||
app.include_router(
|
||||
vision_solver.router, prefix="/api/v1", tags=["vision-solver"], dependencies=_auth
|
||||
)
|
||||
app.include_router(
|
||||
tutorbot.router, prefix="/api/v1/tutorbot", tags=["tutorbot"], dependencies=_auth
|
||||
)
|
||||
|
||||
# Unified WebSocket endpoint — auth is checked inside the handler (WebSockets
|
||||
# cannot use FastAPI dependencies in the standard way)
|
||||
app.include_router(unified_ws.router, prefix="/api/v1", tags=["unified-ws"])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Auth router — login, logout, status, registration, and user-management endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from deeptutor.logging import get_logger
|
||||
from deeptutor.services.auth import (
|
||||
AUTH_ENABLED,
|
||||
TOKEN_EXPIRE_HOURS,
|
||||
TokenPayload,
|
||||
add_user,
|
||||
authenticate,
|
||||
create_token,
|
||||
decode_token,
|
||||
delete_user,
|
||||
is_first_user,
|
||||
list_users,
|
||||
set_role,
|
||||
)
|
||||
|
||||
logger = get_logger("Auth")
|
||||
|
||||
router = APIRouter()
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
_COOKIE_NAME = "dt_token"
|
||||
_COOKIE_MAX_AGE = TOKEN_EXPIRE_HOURS * 3600
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Payload for the POST /login endpoint."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
"""Payload for the POST /register endpoint."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def username_valid(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Username cannot be empty")
|
||||
if len(v) < 3:
|
||||
raise ValueError("Username must be at least 3 characters")
|
||||
if len(v) > 32:
|
||||
raise ValueError("Username must be at most 32 characters")
|
||||
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.")
|
||||
if not all(c in allowed for c in v):
|
||||
raise ValueError("Username may only contain letters, digits, -, _, and .")
|
||||
return v
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_valid(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
return v
|
||||
|
||||
|
||||
class SetRoleRequest(BaseModel):
|
||||
"""Payload for the PUT /users/{username}/role endpoint."""
|
||||
|
||||
role: str
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def role_valid(cls, v: str) -> str:
|
||||
if v not in ("admin", "user"):
|
||||
raise ValueError("Role must be 'admin' or 'user'")
|
||||
return v
|
||||
|
||||
|
||||
class AuthStatusResponse(BaseModel):
|
||||
"""Response body for the GET /status endpoint."""
|
||||
|
||||
enabled: bool
|
||||
authenticated: bool
|
||||
username: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
"""Single user record returned by the GET /users endpoint."""
|
||||
|
||||
username: str
|
||||
role: str
|
||||
created_at: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helper — extract token from cookie or Bearer header
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_token(
|
||||
credentials: HTTPAuthorizationCredentials | None,
|
||||
dt_token: str | None,
|
||||
) -> str | None:
|
||||
if credentials:
|
||||
return credentials.credentials
|
||||
return dt_token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependencies — reusable auth guards for other routers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def require_auth(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
dt_token: str | None = Cookie(default=None),
|
||||
) -> TokenPayload | None:
|
||||
"""
|
||||
FastAPI dependency that enforces authentication when AUTH_ENABLED=true.
|
||||
|
||||
Accepts the JWT from either:
|
||||
- Authorization: Bearer <token> header
|
||||
- dt_token cookie
|
||||
|
||||
Returns the authenticated TokenPayload, or None if auth is disabled.
|
||||
Raises HTTP 401 if auth is enabled but the token is missing or invalid.
|
||||
"""
|
||||
if not AUTH_ENABLED:
|
||||
return None
|
||||
|
||||
token = _extract_token(credentials, dt_token)
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def require_admin(
|
||||
payload: TokenPayload | None = Depends(require_auth),
|
||||
) -> TokenPayload:
|
||||
"""
|
||||
FastAPI dependency that requires the caller to be an admin.
|
||||
|
||||
Raises HTTP 403 if the authenticated user is not an admin.
|
||||
When AUTH_ENABLED=false, all requests are treated as admin.
|
||||
"""
|
||||
if not AUTH_ENABLED:
|
||||
from deeptutor.services.auth import TokenPayload as TP
|
||||
|
||||
return TP(username="local", role="admin")
|
||||
|
||||
if payload is None or payload.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public endpoints (no auth required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/status", response_model=AuthStatusResponse)
|
||||
async def auth_status(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
dt_token: str | None = Cookie(default=None),
|
||||
) -> AuthStatusResponse:
|
||||
"""Return whether auth is enabled and whether the current request is authenticated."""
|
||||
if not AUTH_ENABLED:
|
||||
return AuthStatusResponse(enabled=False, authenticated=True, role="admin")
|
||||
|
||||
token = _extract_token(credentials, dt_token)
|
||||
payload = decode_token(token) if token else None
|
||||
return AuthStatusResponse(
|
||||
enabled=True,
|
||||
authenticated=payload is not None,
|
||||
username=payload.username if payload else None,
|
||||
role=payload.role if payload else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginRequest, response: Response) -> dict:
|
||||
"""Validate credentials and set a JWT cookie."""
|
||||
if not AUTH_ENABLED:
|
||||
return {"ok": True, "message": "Auth is disabled — no login required."}
|
||||
|
||||
result = authenticate(body.username, body.password)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
token = create_token(result.username, result.role)
|
||||
response.set_cookie(
|
||||
key=_COOKIE_NAME,
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
secure=False, # Set to True when served over HTTPS
|
||||
)
|
||||
|
||||
logger.info(f"User '{result.username}' logged in (role={result.role!r})")
|
||||
return {"ok": True, "username": result.username, "role": result.role}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response) -> dict:
|
||||
"""Clear the JWT cookie."""
|
||||
response.delete_cookie(key=_COOKIE_NAME, samesite="lax")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(body: RegisterRequest) -> dict:
|
||||
"""
|
||||
Create a new user account.
|
||||
|
||||
The very first user to register is automatically granted admin privileges.
|
||||
Subsequent registrations create regular users.
|
||||
|
||||
Only available when AUTH_ENABLED=true.
|
||||
"""
|
||||
if not AUTH_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Auth is disabled — registration is not available.",
|
||||
)
|
||||
|
||||
# Check whether this will be the first (admin) user before writing
|
||||
first = is_first_user()
|
||||
|
||||
# Prevent duplicate usernames
|
||||
existing = {u["username"] for u in list_users()}
|
||||
if body.username in existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Username already taken",
|
||||
)
|
||||
|
||||
add_user(body.username, body.password)
|
||||
role = "admin" if first else "user"
|
||||
logger.info(f"New user registered: '{body.username}' (role={role!r})")
|
||||
return {"ok": True, "username": body.username, "role": role, "is_first_user": first}
|
||||
|
||||
|
||||
@router.get("/is_first_user")
|
||||
async def check_is_first_user() -> dict:
|
||||
"""Return whether the user store is empty (used by the register UI)."""
|
||||
return {"is_first_user": is_first_user() if AUTH_ENABLED else False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-only endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserInfo])
|
||||
async def get_users(_: TokenPayload = Depends(require_admin)) -> list[UserInfo]:
|
||||
"""List all registered users. Requires admin role."""
|
||||
return [UserInfo(**u) for u in list_users()]
|
||||
|
||||
|
||||
@router.delete("/users/{username}", status_code=status.HTTP_200_OK)
|
||||
async def remove_user(
|
||||
username: str,
|
||||
current: TokenPayload = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Delete a user. Admins cannot delete their own account."""
|
||||
if current and username == current.username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot delete your own account",
|
||||
)
|
||||
|
||||
removed = delete_user(username)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
logger.info(f"Admin '{current.username if current else 'local'}' deleted user '{username}'")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/users/{username}/role", status_code=status.HTTP_200_OK)
|
||||
async def update_user_role(
|
||||
username: str,
|
||||
body: SetRoleRequest,
|
||||
current: TokenPayload = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Change a user's role. Admins cannot change their own role."""
|
||||
if current and username == current.username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot change your own role",
|
||||
)
|
||||
|
||||
updated = set_role(username, body.role)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
logger.info(
|
||||
f"Admin '{current.username if current else 'local'}' set '{username}' role to {body.role!r}"
|
||||
)
|
||||
return {"ok": True, "username": username, "role": body.role}
|
||||
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Authentication service for DeepTutor.
|
||||
|
||||
Disabled by default (AUTH_ENABLED=false) so localhost users are unaffected.
|
||||
When enabled, guards all API routes with JWT bearer tokens.
|
||||
|
||||
Quick setup (single user via env vars):
|
||||
1. Set AUTH_ENABLED=true in .env
|
||||
2. Set AUTH_USERNAME=<your username>
|
||||
3. Generate a password hash:
|
||||
python -c "from deeptutor.services.auth import hash_password; print(hash_password('yourpassword'))"
|
||||
Paste the output into AUTH_PASSWORD_HASH=<hash>
|
||||
4. Set AUTH_SECRET to a long random string
|
||||
|
||||
Multi-user setup (recommended):
|
||||
Enable AUTH_ENABLED=true and leave AUTH_USERNAME/AUTH_PASSWORD_HASH empty.
|
||||
Navigate to /register in the browser. The first user to register is granted
|
||||
admin privileges and can manage other users from /admin/users.
|
||||
|
||||
Users are stored in data/user/auth_users.json:
|
||||
{
|
||||
"alice": {"hash": "$2b$12$...", "role": "admin", "created_at": "2026-..."},
|
||||
"bob": {"hash": "$2b$12$...", "role": "user", "created_at": "2026-..."}
|
||||
}
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
|
||||
from deeptutor.logging import get_logger
|
||||
|
||||
logger = get_logger("Auth")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration — read once at import time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUTH_ENABLED: bool = os.getenv("AUTH_ENABLED", "false").lower() == "true"
|
||||
AUTH_USERNAME: str = os.getenv("AUTH_USERNAME", "admin")
|
||||
AUTH_PASSWORD_HASH: str = os.getenv("AUTH_PASSWORD_HASH", "")
|
||||
AUTH_SECRET: str = os.getenv("AUTH_SECRET", "")
|
||||
TOKEN_EXPIRE_HOURS: int = int(os.getenv("AUTH_TOKEN_EXPIRE_HOURS", "24"))
|
||||
|
||||
_ALGORITHM = "HS256"
|
||||
_USERS_FILE = Path("data/user/auth_users.json")
|
||||
|
||||
if AUTH_ENABLED and not AUTH_SECRET:
|
||||
logger.warning(
|
||||
"AUTH_ENABLED=true but AUTH_SECRET is not set. "
|
||||
"A temporary secret will be generated — tokens will be invalidated on restart. "
|
||||
"Set AUTH_SECRET in .env to a stable random value."
|
||||
)
|
||||
AUTH_SECRET = secrets.token_hex(32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenPayload:
|
||||
"""Decoded JWT payload."""
|
||||
|
||||
username: str
|
||||
role: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing — uses bcrypt directly (passlib is unmaintained for bcrypt 4+)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plaintext password. Use this to generate password hashes."""
|
||||
import bcrypt
|
||||
|
||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""Verify a plaintext password against a stored bcrypt hash."""
|
||||
import bcrypt
|
||||
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User store — JSON file takes priority over env vars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user_record(hashed: str, role: str = "user", created_at: str = "") -> dict:
|
||||
"""Build a canonical user record dict."""
|
||||
return {
|
||||
"hash": hashed,
|
||||
"role": role,
|
||||
"created_at": created_at or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _load_users() -> dict[str, dict]:
|
||||
"""
|
||||
Load the user store, migrating old flat format if needed.
|
||||
|
||||
Priority:
|
||||
1. data/user/auth_users.json — multi-user file
|
||||
2. AUTH_USERNAME + AUTH_PASSWORD_HASH env vars — single-user fallback
|
||||
|
||||
Old format: {"alice": "$2b$12$..."}
|
||||
New format: {"alice": {"hash": "...", "role": "admin", "created_at": "..."}}
|
||||
"""
|
||||
if _USERS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(_USERS_FILE.read_text())
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("auth_users.json is not a JSON object — falling back to env vars")
|
||||
data = {}
|
||||
|
||||
migrated = False
|
||||
users: dict[str, dict] = {}
|
||||
for username, value in data.items():
|
||||
if isinstance(value, str):
|
||||
# Migrate old flat hash string — first user in old file gets admin
|
||||
role = "admin" if not users else "user"
|
||||
users[username] = _make_user_record(value, role=role)
|
||||
migrated = True
|
||||
elif isinstance(value, dict):
|
||||
users[username] = value
|
||||
else:
|
||||
logger.warning(f"Skipping malformed user entry: {username!r}")
|
||||
|
||||
if migrated:
|
||||
_USERS_FILE.write_text(json.dumps(users, indent=2))
|
||||
logger.info("Migrated auth_users.json to new schema with role/created_at fields")
|
||||
|
||||
return users
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to read auth_users.json: {exc} — falling back to env vars")
|
||||
|
||||
# Env-var single-user fallback — always treated as admin
|
||||
if AUTH_USERNAME and AUTH_PASSWORD_HASH:
|
||||
return {AUTH_USERNAME: _make_user_record(AUTH_PASSWORD_HASH, role="admin", created_at="")}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def is_first_user() -> bool:
|
||||
"""Return True when no users exist yet (first registration will become admin)."""
|
||||
return len(_load_users()) == 0
|
||||
|
||||
|
||||
def add_user(username: str, plain_password: str, role: str = "user") -> None:
|
||||
"""
|
||||
Add or update a user in data/user/auth_users.json.
|
||||
|
||||
The role defaults to 'user'. Pass role='admin' to elevate. When the store
|
||||
is empty the first user is automatically promoted to 'admin' regardless of
|
||||
the role argument.
|
||||
|
||||
Creates the file (and parent directories) if they don't exist.
|
||||
"""
|
||||
_USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
users: dict[str, dict] = {}
|
||||
if _USERS_FILE.exists():
|
||||
try:
|
||||
users = json.loads(_USERS_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
effective_role = "admin" if not users else role
|
||||
users[username] = _make_user_record(hash_password(plain_password), role=effective_role)
|
||||
_USERS_FILE.write_text(json.dumps(users, indent=2))
|
||||
logger.info(f"User '{username}' saved to {_USERS_FILE} with role={effective_role!r}")
|
||||
|
||||
|
||||
def list_users() -> list[dict]:
|
||||
"""Return a list of user info dicts (username, role, created_at) — no hashes."""
|
||||
users = _load_users()
|
||||
return [
|
||||
{
|
||||
"username": username,
|
||||
"role": record.get("role", "user"),
|
||||
"created_at": record.get("created_at", ""),
|
||||
}
|
||||
for username, record in users.items()
|
||||
]
|
||||
|
||||
|
||||
def delete_user(username: str) -> bool:
|
||||
"""
|
||||
Remove a user from the store. Returns True if the user existed.
|
||||
|
||||
Note: env-var-only users cannot be deleted via this function.
|
||||
"""
|
||||
if not _USERS_FILE.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
users: dict[str, dict] = json.loads(_USERS_FILE.read_text())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if username not in users:
|
||||
return False
|
||||
|
||||
del users[username]
|
||||
_USERS_FILE.write_text(json.dumps(users, indent=2))
|
||||
logger.info(f"User '{username}' deleted from {_USERS_FILE}")
|
||||
return True
|
||||
|
||||
|
||||
def set_role(username: str, role: str) -> bool:
|
||||
"""
|
||||
Change the role for an existing user. Returns True on success.
|
||||
|
||||
Valid roles: 'admin', 'user'.
|
||||
"""
|
||||
if role not in ("admin", "user"):
|
||||
raise ValueError(f"Invalid role: {role!r}. Must be 'admin' or 'user'.")
|
||||
|
||||
if not _USERS_FILE.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
users: dict[str, dict] = json.loads(_USERS_FILE.read_text())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if username not in users:
|
||||
return False
|
||||
|
||||
users[username]["role"] = role
|
||||
_USERS_FILE.write_text(json.dumps(users, indent=2))
|
||||
logger.info(f"User '{username}' role updated to {role!r}")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_token(username: str, role: str = "user") -> str:
|
||||
"""Create a signed JWT for the given username and role."""
|
||||
from jose import jwt
|
||||
|
||||
payload = {
|
||||
"sub": username,
|
||||
"role": role,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRE_HOURS),
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, AUTH_SECRET, algorithm=_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> TokenPayload | None:
|
||||
"""Decode and validate a JWT. Returns a TokenPayload or None if invalid."""
|
||||
from jose import JWTError, jwt
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, AUTH_SECRET, algorithms=[_ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
return None
|
||||
return TokenPayload(username=username, role=payload.get("role", "user"))
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main auth entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> TokenPayload | None:
|
||||
"""
|
||||
Validate credentials. Returns a TokenPayload on success, None on failure.
|
||||
|
||||
When AUTH_ENABLED=false, always returns a dummy admin payload so that
|
||||
callers don't need to special-case the disabled state.
|
||||
"""
|
||||
if not AUTH_ENABLED:
|
||||
return TokenPayload(username=username or "local", role="admin")
|
||||
|
||||
users = _load_users()
|
||||
if not users:
|
||||
logger.warning(
|
||||
"No users configured — login will always fail. "
|
||||
"Navigate to /register to create your first account."
|
||||
)
|
||||
return None
|
||||
|
||||
record = users.get(username)
|
||||
if not record:
|
||||
return None
|
||||
|
||||
hashed = record.get("hash", "") if isinstance(record, dict) else record
|
||||
if not verify_password(password, hashed):
|
||||
return None
|
||||
|
||||
role = record.get("role", "user") if isinstance(record, dict) else "user"
|
||||
return TokenPayload(username=username, role=role)
|
||||
+5
-1
@@ -45,13 +45,17 @@ server = [
|
||||
"uvicorn[standard]>=0.24.0",
|
||||
"websockets>=12.0",
|
||||
"python-multipart>=0.0.6",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"bcrypt>=4.0.0",
|
||||
]
|
||||
math-animator = ["manim>=0.19.0"]
|
||||
all = [
|
||||
"anthropic>=0.30.0",
|
||||
"dashscope>=1.14.0",
|
||||
"perplexityai>=0.1.0",
|
||||
"oauth-cli-kit>=0.1.1; python_version >= '3.11'",
|
||||
"oauth-cli-kit>=0.2.0; python_version >= '3.11'",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"bcrypt>=4.0.0",
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn[standard]>=0.24.0",
|
||||
"websockets>=12.0",
|
||||
|
||||
@@ -11,3 +11,7 @@ fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
websockets>=12.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# --- Authentication ---
|
||||
bcrypt>=4.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
|
||||
+1
-10
@@ -1,11 +1,2 @@
|
||||
# ============================================
|
||||
# Auto-generated by start_web.py
|
||||
# ============================================
|
||||
# This file is automatically updated based on config/main.yaml
|
||||
# and environment variables (NEXT_PUBLIC_API_BASE, NEXT_PUBLIC_API_BASE_EXTERNAL)
|
||||
#
|
||||
# To configure for remote access, set in your .env file:
|
||||
# NEXT_PUBLIC_API_BASE=http://your-server-ip:8001
|
||||
# ============================================
|
||||
|
||||
NEXT_PUBLIC_API_BASE=http://localhost:8001
|
||||
NEXT_PUBLIC_AUTH_ENABLED=false
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { fetchAuthStatus } from "@/lib/auth";
|
||||
import {
|
||||
listUsers,
|
||||
deleteUser,
|
||||
setUserRole,
|
||||
type UserRecord,
|
||||
} from "@/lib/admin-api";
|
||||
import { Shield, ShieldOff, Trash2, RefreshCw, ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const router = useRouter();
|
||||
const [currentUser, setCurrentUser] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listUsers();
|
||||
setUsers(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load users");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuthStatus().then((status) => {
|
||||
if (!status?.authenticated) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
if (status.role !== "admin") {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
setCurrentUser(status.username ?? null);
|
||||
void load();
|
||||
});
|
||||
}, [router, load]);
|
||||
|
||||
async function handleDelete(username: string) {
|
||||
if (!window.confirm(`Delete user "${username}"? This cannot be undone.`))
|
||||
return;
|
||||
setActionError("");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
setUsers((prev) => prev.filter((u) => u.username !== username));
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : "Failed to delete user");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRole(user: UserRecord) {
|
||||
const newRole = user.role === "admin" ? "user" : "admin";
|
||||
const verb = newRole === "admin" ? "Promote" : "Demote";
|
||||
if (!window.confirm(`${verb} "${user.username}" to ${newRole}?`)) return;
|
||||
setActionError("");
|
||||
try {
|
||||
await setUserRole(user.username, newRole);
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.username === user.username ? { ...u, role: newRole } : u,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : "Failed to update role");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] px-4 py-10">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
Back
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-semibold text-[var(--foreground)]">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-[var(--muted-foreground)]">
|
||||
Manage registered accounts
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm
|
||||
border border-[var(--border)] text-[var(--muted-foreground)]
|
||||
hover:text-[var(--foreground)] hover:bg-[var(--card)]
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] overflow-hidden shadow-sm">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-[var(--muted-foreground)] text-sm">
|
||||
Loading…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16 text-[var(--muted-foreground)] text-sm">
|
||||
No users found.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)] text-left text-xs text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
<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">Joined</th>
|
||||
<th className="px-5 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border)]">
|
||||
{users.map((user) => {
|
||||
const isSelf = user.username === currentUser;
|
||||
return (
|
||||
<tr
|
||||
key={user.username}
|
||||
className="group hover:bg-[var(--background)]/50 transition-colors"
|
||||
>
|
||||
<td className="px-5 py-3.5 font-medium text-[var(--foreground)]">
|
||||
{user.username}
|
||||
{isSelf && (
|
||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||
(you)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium
|
||||
${
|
||||
user.role === "admin"
|
||||
? "bg-purple-500/15 text-purple-600 dark:text-purple-400"
|
||||
: "bg-[var(--muted)]/50 text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
{user.role === "admin" ? <Shield size={11} /> : null}
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--muted-foreground)]">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
onClick={() => handleToggleRole(user)}
|
||||
disabled={isSelf}
|
||||
title={
|
||||
isSelf
|
||||
? "Cannot change your own role"
|
||||
: user.role === "admin"
|
||||
? "Demote to user"
|
||||
: "Promote to admin"
|
||||
}
|
||||
className="rounded-lg p-1.5 text-[var(--muted-foreground)]
|
||||
hover:bg-[var(--background)] hover:text-[var(--foreground)]
|
||||
disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{user.role === "admin" ? (
|
||||
<ShieldOff size={15} />
|
||||
) : (
|
||||
<Shield size={15} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.username)}
|
||||
disabled={isSelf}
|
||||
title={
|
||||
isSelf
|
||||
? "Cannot delete your own account"
|
||||
: `Delete ${user.username}`
|
||||
}
|
||||
className="rounded-lg p-1.5 text-[var(--muted-foreground)]
|
||||
hover:bg-red-500/10 hover:text-red-500
|
||||
disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-xs text-[var(--muted-foreground)]">
|
||||
DeepTutor Admin · User Management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="min-h-screen bg-[var(--background)]">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { login, fetchAuthStatus, checkIsFirstUser } from "@/lib/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const next = searchParams.get("next") ?? "/";
|
||||
|
||||
const registered = searchParams.get("registered") === "1";
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If already authenticated, skip login
|
||||
fetchAuthStatus().then((status) => {
|
||||
if (status?.authenticated) {
|
||||
router.replace(next);
|
||||
return;
|
||||
}
|
||||
// No users registered yet — send straight to the registration page
|
||||
checkIsFirstUser().then((first) => {
|
||||
if (first) router.replace("/register");
|
||||
});
|
||||
});
|
||||
}, [router, next]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(username, password);
|
||||
|
||||
if (result.ok) {
|
||||
router.replace(next);
|
||||
} else {
|
||||
setError(result.error ?? "Login failed");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo / Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-[var(--foreground)] tracking-tight">
|
||||
DeepTutor
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Registered success notice */}
|
||||
{registered && (
|
||||
<div className="mb-4 rounded-lg border border-green-500/30 bg-green-500/10 px-4 py-3 text-sm text-green-600 dark:text-green-400">
|
||||
Account created! Sign in to continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-2xl shadow-sm px-8 py-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-[var(--foreground)] mb-1.5"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-[var(--border)]
|
||||
bg-[var(--background)] text-[var(--foreground)]
|
||||
placeholder:text-[var(--muted-foreground)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent
|
||||
transition-shadow text-sm"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-[var(--foreground)] mb-1.5"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-[var(--border)]
|
||||
bg-[var(--background)] text-[var(--foreground)]
|
||||
placeholder:text-[var(--muted-foreground)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent
|
||||
transition-shadow text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 bg-red-500/10 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-lg font-medium text-sm
|
||||
bg-[var(--primary)] text-[var(--primary-foreground)]
|
||||
hover:opacity-90 active:opacity-80
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-opacity"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-[var(--primary)] hover:underline font-medium"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="mt-3 text-center text-xs text-[var(--muted-foreground)]">
|
||||
DeepTutor · Agent-Native Learning
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { register, checkIsFirstUser, fetchAuthStatus } from "@/lib/auth";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isFirst, setIsFirst] = useState(false);
|
||||
const [checkingFirst, setCheckingFirst] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect if already logged in
|
||||
fetchAuthStatus().then((status) => {
|
||||
if (status?.authenticated) router.replace("/");
|
||||
});
|
||||
|
||||
// Check if this will be the first (admin) user
|
||||
checkIsFirstUser().then((first) => {
|
||||
setIsFirst(first);
|
||||
setCheckingFirst(false);
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const result = await register(username, password);
|
||||
|
||||
if (result.ok) {
|
||||
router.replace("/login?registered=1");
|
||||
} else {
|
||||
setError(result.error ?? "Registration failed");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo / Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-[var(--foreground)] tracking-tight">
|
||||
DeepTutor
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
Create your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* First-user notice */}
|
||||
{!checkingFirst && isFirst && (
|
||||
<div className="mb-4 rounded-lg border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
<strong>First user:</strong> You will be granted admin privileges and
|
||||
can manage other users from the admin dashboard.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-2xl shadow-sm px-8 py-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-[var(--foreground)] mb-1.5"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-[var(--border)]
|
||||
bg-[var(--background)] text-[var(--foreground)]
|
||||
placeholder:text-[var(--muted-foreground)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent
|
||||
transition-shadow text-sm"
|
||||
placeholder="your_username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-[var(--foreground)] mb-1.5"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-[var(--border)]
|
||||
bg-[var(--background)] text-[var(--foreground)]
|
||||
placeholder:text-[var(--muted-foreground)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent
|
||||
transition-shadow text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
At least 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-[var(--foreground)] mb-1.5"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-[var(--border)]
|
||||
bg-[var(--background)] text-[var(--foreground)]
|
||||
placeholder:text-[var(--muted-foreground)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent
|
||||
transition-shadow text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 bg-red-500/10 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-lg font-medium text-sm
|
||||
bg-[var(--primary)] text-[var(--primary-foreground)]
|
||||
hover:opacity-90 active:opacity-80
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-opacity"
|
||||
>
|
||||
{loading ? "Creating account…" : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[var(--primary)] hover:underline font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="mt-3 text-center text-xs text-[var(--muted-foreground)]">
|
||||
DeepTutor · Agent-Native Learning
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { fetchAuthStatus, AUTH_ENABLED } from "@/lib/auth";
|
||||
|
||||
interface AdminLinkProps {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export function AdminLink({ collapsed = false }: AdminLinkProps) {
|
||||
const pathname = usePathname();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!AUTH_ENABLED) return;
|
||||
fetchAuthStatus().then((status) => {
|
||||
setIsAdmin(status?.role === "admin");
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!AUTH_ENABLED || !isAdmin) return null;
|
||||
|
||||
const active = pathname.startsWith("/admin");
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className={`rounded-lg p-2 transition-colors
|
||||
${
|
||||
active
|
||||
? "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)] hover:bg-[var(--background)]/50 hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
aria-label="Admin"
|
||||
title="Admin — User Management"
|
||||
>
|
||||
<ShieldCheck size={16} strokeWidth={1.5} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] transition-colors
|
||||
${
|
||||
active
|
||||
? "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)] hover:bg-[var(--background)]/50 hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck size={16} strokeWidth={1.5} />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AUTH_ENABLED, logout } from "@/lib/auth";
|
||||
|
||||
interface LogoutButtonProps {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export function LogoutButton({ collapsed = false }: LogoutButtonProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!AUTH_ENABLED) return null;
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-lg p-2 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--background)]/50 hover:text-red-500"
|
||||
aria-label="Sign out"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--background)]/50 hover:text-red-500"
|
||||
>
|
||||
<LogOut size={16} strokeWidth={1.5} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,9 @@ const PRIMARY_NAV: NavEntry[] = [
|
||||
{ href: "/memory", label: "Memory", icon: Brain },
|
||||
];
|
||||
|
||||
const SECONDARY_NAV: NavEntry[] = [{ href: "/settings", label: "Settings", icon: Settings }];
|
||||
const SECONDARY_NAV: NavEntry[] = [
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
const DEFAULT_SESSION_VIEWPORT_CLASS_NAME = "max-h-[112px]";
|
||||
|
||||
interface SidebarShellProps {
|
||||
@@ -100,7 +102,10 @@ export function SidebarShell({
|
||||
|
||||
<nav className="flex flex-col items-center gap-px pt-1">
|
||||
{PRIMARY_NAV.map((item) => {
|
||||
const active = pathname.startsWith(item.href);
|
||||
const active =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(item.href);
|
||||
return (
|
||||
<div key={item.href} className="flex flex-col items-center">
|
||||
<Link
|
||||
@@ -114,7 +119,6 @@ export function SidebarShell({
|
||||
<item.icon size={16} strokeWidth={active ? 1.9 : 1.5} />
|
||||
</Link>
|
||||
{item.href === "/agents" && <TutorBotRecent collapsed />}
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -139,7 +143,7 @@ export function SidebarShell({
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{footerSlot}
|
||||
{footerSlot && <div className="mt-1">{footerSlot}</div>}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@@ -178,8 +182,16 @@ export function SidebarShell({
|
||||
</button>
|
||||
|
||||
{PRIMARY_NAV.map((item) => {
|
||||
const active = pathname.startsWith(item.href);
|
||||
const hasSessionsBelow = item.href === "/chat" && showSessions && onSelectSession && onRenameSession && onDeleteSession;
|
||||
const active =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(item.href);
|
||||
const hasSessionsBelow =
|
||||
item.href === "/" &&
|
||||
showSessions &&
|
||||
onSelectSession &&
|
||||
onRenameSession &&
|
||||
onDeleteSession;
|
||||
const hasBots = item.href === "/agents";
|
||||
return (
|
||||
<div key={item.href}>
|
||||
@@ -195,7 +207,9 @@ export function SidebarShell({
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
{hasSessionsBelow && (
|
||||
<div className={`${sessionViewportClassName} overflow-y-auto`}>
|
||||
<div
|
||||
className={`${sessionViewportClassName} overflow-y-auto`}
|
||||
>
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SidebarShell } from "@/components/sidebar/SidebarShell";
|
||||
import { LogoutButton } from "@/components/auth/LogoutButton";
|
||||
import { AdminLink } from "@/components/auth/AdminLink";
|
||||
import { useAppShell } from "@/context/AppShellContext";
|
||||
import {
|
||||
deleteSession,
|
||||
@@ -51,22 +53,31 @@ export default function UtilitySidebar() {
|
||||
[router, setActiveSessionId],
|
||||
);
|
||||
|
||||
const handleRenameSession = useCallback(async (sessionId: string, title: string) => {
|
||||
const updated = await updateSessionTitle(sessionId, title);
|
||||
setSessions((prev) =>
|
||||
prev.map((session) =>
|
||||
session.session_id === sessionId
|
||||
? { ...session, title: updated.title, updated_at: updated.updated_at }
|
||||
: session,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const handleRenameSession = useCallback(
|
||||
async (sessionId: string, title: string) => {
|
||||
const updated = await updateSessionTitle(sessionId, title);
|
||||
setSessions((prev) =>
|
||||
prev.map((session) =>
|
||||
session.session_id === sessionId
|
||||
? {
|
||||
...session,
|
||||
title: updated.title,
|
||||
updated_at: updated.updated_at,
|
||||
}
|
||||
: session,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!window.confirm(t("Delete this chat history?"))) return;
|
||||
await deleteSession(sessionId);
|
||||
setSessions((prev) => prev.filter((session) => session.session_id !== sessionId));
|
||||
setSessions((prev) =>
|
||||
prev.filter((session) => session.session_id !== sessionId),
|
||||
);
|
||||
if (activeSessionId === sessionId) {
|
||||
setActiveSessionId(null);
|
||||
}
|
||||
@@ -84,6 +95,12 @@ export default function UtilitySidebar() {
|
||||
onSelectSession={handleSelectSession}
|
||||
onRenameSession={handleRenameSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
footerSlot={
|
||||
<>
|
||||
<AdminLink />
|
||||
<LogoutButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SidebarShell } from "@/components/sidebar/SidebarShell";
|
||||
import { LogoutButton } from "@/components/auth/LogoutButton";
|
||||
import { AdminLink } from "@/components/auth/AdminLink";
|
||||
import { useUnifiedChat } from "@/context/UnifiedChatContext";
|
||||
import {
|
||||
deleteSession,
|
||||
@@ -15,8 +17,13 @@ import {
|
||||
export default function WorkspaceSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { newSession, selectedSessionId, sessionStatuses, sidebarRefreshToken } =
|
||||
useUnifiedChat();
|
||||
const {
|
||||
newSession,
|
||||
loadSession,
|
||||
selectedSessionId,
|
||||
sessionStatuses,
|
||||
sidebarRefreshToken,
|
||||
} = useUnifiedChat();
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const hasLoadedSessionsRef = useRef(false);
|
||||
@@ -73,22 +80,31 @@ export default function WorkspaceSidebar() {
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleRenameSession = useCallback(async (sessionId: string, title: string) => {
|
||||
const updated = await updateSessionTitle(sessionId, title);
|
||||
setSessions((prev) =>
|
||||
prev.map((session) =>
|
||||
session.session_id === sessionId
|
||||
? { ...session, title: updated.title, updated_at: updated.updated_at }
|
||||
: session,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const handleRenameSession = useCallback(
|
||||
async (sessionId: string, title: string) => {
|
||||
const updated = await updateSessionTitle(sessionId, title);
|
||||
setSessions((prev) =>
|
||||
prev.map((session) =>
|
||||
session.session_id === sessionId
|
||||
? {
|
||||
...session,
|
||||
title: updated.title,
|
||||
updated_at: updated.updated_at,
|
||||
}
|
||||
: session,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!window.confirm(t("Delete this chat history?"))) return;
|
||||
await deleteSession(sessionId);
|
||||
setSessions((prev) => prev.filter((session) => session.session_id !== sessionId));
|
||||
setSessions((prev) =>
|
||||
prev.filter((session) => session.session_id !== sessionId),
|
||||
);
|
||||
if (selectedSessionId === sessionId) {
|
||||
newSession();
|
||||
router.push("/chat");
|
||||
@@ -107,6 +123,12 @@ export default function WorkspaceSidebar() {
|
||||
onSelectSession={handleSelectSession}
|
||||
onRenameSession={handleRenameSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
footerSlot={
|
||||
<>
|
||||
<AdminLink />
|
||||
<LogoutButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { apiUrl } from "@/lib/api";
|
||||
|
||||
export interface UserRecord {
|
||||
username: string;
|
||||
role: "admin" | "user";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<UserRecord[]> {
|
||||
const res = await fetch(apiUrl("/api/v1/auth/users"), {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch users");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteUser(username: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
apiUrl(`/api/v1/auth/users/${encodeURIComponent(username)}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail ?? "Failed to delete user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserRole(
|
||||
username: string,
|
||||
role: "admin" | "user",
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
apiUrl(`/api/v1/auth/users/${encodeURIComponent(username)}/role`),
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ role }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail ?? "Failed to update role");
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -10,7 +10,9 @@ export const API_BASE_URL =
|
||||
console.error(
|
||||
"Please configure NEXT_PUBLIC_API_BASE in your environment and restart the application.",
|
||||
);
|
||||
console.error("Run python scripts/start_tour.py to rebuild your local setup if needed.");
|
||||
console.error(
|
||||
"Run python scripts/start_tour.py to rebuild your local setup if needed.",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_API_BASE is not configured. Please set it in your environment and restart.",
|
||||
@@ -52,3 +54,24 @@ export function wsUrl(path: string): string {
|
||||
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
const AUTH_ENABLED = process.env.NEXT_PUBLIC_AUTH_ENABLED === "true";
|
||||
|
||||
/**
|
||||
* Authenticated fetch wrapper. Behaves identically to `fetch` but automatically
|
||||
* redirects to /login when the backend returns 401 (expired / invalid token).
|
||||
*/
|
||||
export async function apiFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const res = await fetch(input, { credentials: "include", ...init });
|
||||
|
||||
if (res.status === 401 && AUTH_ENABLED && typeof window !== "undefined") {
|
||||
const next = encodeURIComponent(window.location.pathname);
|
||||
window.location.href = `/login?next=${next}`;
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { apiUrl } from "@/lib/api";
|
||||
|
||||
export const AUTH_ENABLED = process.env.NEXT_PUBLIC_AUTH_ENABLED === "true";
|
||||
|
||||
export interface AuthStatus {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
username?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the backend to check whether the current session is authenticated.
|
||||
* Returns null on network error so callers can decide how to handle it.
|
||||
*/
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus | null> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/v1/auth/status"), {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST credentials to the backend. Returns true on success.
|
||||
*/
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/v1/auth/login"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (res.ok) return { ok: true };
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: data.detail ?? "Login failed" };
|
||||
} catch {
|
||||
return { ok: false, error: "Could not reach the server" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new account. The first user to register becomes admin.
|
||||
*/
|
||||
export async function register(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
role?: string;
|
||||
is_first_user?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/v1/auth/register"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok)
|
||||
return { ok: true, role: data.role, is_first_user: data.is_first_user };
|
||||
return { ok: false, error: data.detail ?? "Registration failed" };
|
||||
} catch {
|
||||
return { ok: false, error: "Could not reach the server" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user store is empty (first user will become admin).
|
||||
*/
|
||||
export async function checkIsFirstUser(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/v1/auth/is_first_user"), {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return Boolean(data.is_first_user);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST to the logout endpoint to clear the session cookie.
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await fetch(apiUrl("/api/v1/auth/logout"), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
// Ignore — we'll redirect regardless
|
||||
}
|
||||
}
|
||||
+42
-11
@@ -27,7 +27,13 @@ export interface SessionSummary {
|
||||
updated_at: number;
|
||||
message_count: number;
|
||||
last_message: string;
|
||||
status?: "idle" | "running" | "completed" | "failed" | "cancelled" | "rejected";
|
||||
status?:
|
||||
| "idle"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "rejected";
|
||||
active_turn_id?: string;
|
||||
preferences?: {
|
||||
capability?: string;
|
||||
@@ -56,7 +62,13 @@ export interface SessionDetail {
|
||||
title: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
status?: "idle" | "running" | "completed" | "failed" | "cancelled" | "rejected";
|
||||
status?:
|
||||
| "idle"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "rejected";
|
||||
active_turn_id?: string;
|
||||
compressed_summary?: string;
|
||||
summary_up_to_msg_id?: number;
|
||||
@@ -83,6 +95,11 @@ export interface QuizResultItem {
|
||||
}
|
||||
|
||||
async function expectJson<T>(response: Response): Promise<T> {
|
||||
if (response.status === 401 && typeof window !== "undefined") {
|
||||
const next = encodeURIComponent(window.location.pathname);
|
||||
window.location.href = `/login?next=${next}`;
|
||||
return new Promise(() => {});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
@@ -97,9 +114,13 @@ export async function listSessions(
|
||||
return withClientCache<SessionSummary[]>(
|
||||
`sessions:${limit}:${offset}`,
|
||||
async () => {
|
||||
const response = await fetch(apiUrl(`/api/v1/sessions?limit=${limit}&offset=${offset}`), {
|
||||
cache: "no-store",
|
||||
});
|
||||
const response = await fetch(
|
||||
apiUrl(`/api/v1/sessions?limit=${limit}&offset=${offset}`),
|
||||
{
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
const data = await expectJson<{ sessions: SessionSummary[] }>(response);
|
||||
return data.sessions ?? [];
|
||||
},
|
||||
@@ -113,14 +134,19 @@ export async function listSessions(
|
||||
export async function getSession(sessionId: string): Promise<SessionDetail> {
|
||||
const response = await fetch(apiUrl(`/api/v1/sessions/${sessionId}`), {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
});
|
||||
return expectJson<SessionDetail>(response);
|
||||
}
|
||||
|
||||
export async function updateSessionTitle(sessionId: string, title: string): Promise<SessionDetail> {
|
||||
export async function updateSessionTitle(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
): Promise<SessionDetail> {
|
||||
const response = await fetch(apiUrl(`/api/v1/sessions/${sessionId}`), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
const data = await expectJson<{ session: SessionDetail }>(response);
|
||||
@@ -131,6 +157,7 @@ export async function updateSessionTitle(sessionId: string, title: string): Prom
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const response = await fetch(apiUrl(`/api/v1/sessions/${sessionId}`), {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
await expectJson<{ deleted: boolean }>(response);
|
||||
invalidateClientCache("sessions:");
|
||||
@@ -140,10 +167,14 @@ export async function recordQuizResults(
|
||||
sessionId: string,
|
||||
answers: QuizResultItem[],
|
||||
): Promise<void> {
|
||||
const response = await fetch(apiUrl(`/api/v1/sessions/${sessionId}/quiz-results`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
const response = await fetch(
|
||||
apiUrl(`/api/v1/sessions/${sessionId}/quiz-results`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ answers }),
|
||||
},
|
||||
);
|
||||
await expectJson<{ recorded: boolean }>(response);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const AUTH_ENABLED = process.env.NEXT_PUBLIC_AUTH_ENABLED === "true";
|
||||
const LOGIN_PATH = "/login";
|
||||
const COOKIE_NAME = "dt_token";
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
// Auth is disabled (default) — let everything through
|
||||
if (!AUTH_ENABLED) return NextResponse.next();
|
||||
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Always allow auth pages and Next.js internals
|
||||
if (
|
||||
pathname.startsWith(LOGIN_PATH) ||
|
||||
pathname.startsWith("/register") ||
|
||||
pathname.startsWith("/_next") ||
|
||||
pathname.startsWith("/favicon")
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = req.cookies.get(COOKIE_NAME)?.value;
|
||||
|
||||
// No token — redirect to login, preserving the intended destination
|
||||
if (!token) {
|
||||
const loginUrl = req.nextUrl.clone();
|
||||
loginUrl.pathname = LOGIN_PATH;
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Run on all page routes, skip API and static assets
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user