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:
xor_xe
2026-04-17 21:40:28 +04:00
parent 727d49cf8b
commit 6688408800
24 changed files with 2067 additions and 99 deletions
+35
View File
@@ -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
View File
@@ -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"
}
+51 -2
View File
@@ -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
View File
@@ -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"])
+328
View File
@@ -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}
+311
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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
View File
@@ -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
+239
View File
@@ -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>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="min-h-screen bg-[var(--background)]">{children}</div>;
}
+11
View File
@@ -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>
);
}
+156
View File
@@ -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&apos;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>
);
}
+187
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+21 -7
View File
@@ -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}
+28 -11
View File
@@ -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 />
</>
}
/>
);
}
+35 -13
View File
@@ -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 />
</>
}
/>
);
}
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+39
View File
@@ -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).*)"],
};