feat: one settings page, and comments removed

Settings linked to a second dashboard with its own tab bar and its own
visual language. The admin sections are rendered in Settings now, under
headings that say who they are for — You, Content, The site — and each
has its own address, so People, AI models, Safety and Search are links.
/admin redirects into Settings for anyone who bookmarked it. AdminPage
takes a `section` prop and drops its tab row when embedded; it is loaded
lazily, so it is not in a learner's download.

Comments are gone: router, model, table and the half of the test file
that covered them. They were a discussion thread nobody was obliged to
answer, and feedback replaced them with a message addressed to whoever
maintains the question. The table was empty, so nothing was lost —
verified before dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 19:40:24 +02:00
parent c20b1e678f
commit 2c5d3c67b4
12 changed files with 228 additions and 272 deletions

View file

@ -0,0 +1,37 @@
"""Drop the comments table.
Comments were a discussion thread under every article and question. Nothing
read them into anyone's work, so they collected opinion nobody was obliged to
answer. Feedback replaced them: a message addressed to whoever maintains the
question, which an educator resolves, replies to, or deletes.
The table was empty when this was written, so nothing is lost. The downgrade
recreates the shape but cannot recreate rows.
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
"""
import sqlalchemy as sa
from alembic import op
revision = "e5f6a7b8c9d0"
down_revision = "d4e5f6a7b8c9"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("comments")
def downgrade() -> None:
op.create_table(
"comments",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("article_id", sa.Integer(), sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=True),
sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("status", sa.String(), server_default="pending"),
sa.Column("created_at", sa.DateTime(), nullable=True),
)

View file

@ -13,7 +13,7 @@ from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
from app.routers import access
from app.routers import feedback
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode
from app.utils.auth import get_password_hash
@ -619,7 +619,6 @@ app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"])
app.include_router(exams.router, prefix="/api/exams", tags=["exams"])
app.include_router(study_plans.router, prefix="/api/study-plans", tags=["study-plans"])
app.include_router(media.router, prefix="/api/media", tags=["media"])
app.include_router(comments.router, prefix="/api/comments", tags=["comments"])
app.include_router(share.router, prefix="/api/share", tags=["share"])
app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])

View file

@ -9,7 +9,6 @@ from app.models.favorite import Favorite
from app.models.user_note import UserNote
from app.models.lab_reference import LabReference, LabReferenceCardLink
from app.models.article import Article, QuestionArticleLink
from app.models.comment import Comment
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.collection import UserCollection, UserCollectionQuestion
@ -29,7 +28,6 @@ __all__ = [
"LabReferenceCardLink",
"Article",
"QuestionArticleLink",
"Comment",
"FlashcardDeck",
"Flashcard",
"FlashcardDeckRating",

View file

@ -1,22 +0,0 @@
from datetime import datetime
from sqlalchemy import Column, Integer, Text, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class Comment(Base):
"""Moderated discussion comment on an article or a question."""
__tablename__ = "comments"
id = Column(Integer, primary_key=True, index=True)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=True)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
content = Column(Text, nullable=False)
status = Column(String, default="pending") # pending | approved | rejected
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User")

View file

@ -1,158 +0,0 @@
"""Moderated comments on articles and questions."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.article import Article
from app.models.comment import Comment
from app.models.question import Question
from app.models.user import User
from app.services.quiz_builder import bank_question_predicate
from app.utils.auth import check_rate_limit, get_current_user, require_moderator
router = APIRouter()
class CommentCreate(BaseModel):
article_id: int | None = None
question_id: int | None = None
content: str
@field_validator("content")
@classmethod
def content_shape(cls, value):
value = value.strip()
if not value:
raise ValueError("Comment cannot be empty")
if len(value) > 2000:
raise ValueError("Comment is too long (max 2000 characters)")
return value
class CommentModerate(BaseModel):
status: str
@field_validator("status")
@classmethod
def status_shape(cls, value):
if value not in ("pending", "approved", "rejected"):
raise ValueError("Invalid moderation status")
return value
def _target(db: Session, article_id: int | None, question_id: int | None, user: User):
if (article_id is None) == (question_id is None):
raise HTTPException(400, "Provide exactly one of article_id or question_id")
if article_id is not None:
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not user.is_moderator:
raise HTTPException(404, "Article not found")
return "article_id", article
question = db.get(Question, question_id)
if not question or not db.query(Question.id).filter(
Question.id == question_id, bank_question_predicate(user)).first():
raise HTTPException(404, "Question not found")
return "question_id", question
@router.post("/")
def create_comment(
data: CommentCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
target_field, _ = _target(db, data.article_id, data.question_id, current_user)
check_rate_limit(
key=f"comments:daily:{current_user.id}",
max_calls=20,
window_seconds=86400,
detail="You've reached today's comment limit. Try again tomorrow.",
user=current_user,
)
comment = Comment(article_id=data.article_id, question_id=data.question_id,
user_id=current_user.id, content=data.content, status="pending")
db.add(comment)
db.commit()
db.refresh(comment)
return _json(db, comment, current_user)
def _json(db: Session, comment: Comment, user: User):
author = db.get(User, comment.user_id) if comment.user_id else None
return {
"id": comment.id,
"article_id": comment.article_id,
"question_id": comment.question_id,
"user_id": comment.user_id,
"author_name": author.name if author else "Unknown",
"content": comment.content,
"status": comment.status,
"created_at": comment.created_at,
"own": comment.user_id == user.id,
"can_moderate": user.is_moderator,
}
@router.get("/")
def list_comments(
article_id: int | None = Query(None),
question_id: int | None = Query(None),
limit: int = Query(20, le=100),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
target_field, target = _target(db, article_id, question_id, current_user)
target_id = getattr(target, "id")
query = db.query(Comment).filter(getattr(Comment, target_field) == target_id).filter(
(Comment.status == "approved") | (Comment.user_id == current_user.id),
)
total = query.count()
comments = query.order_by(Comment.created_at.desc()).offset(offset).limit(limit).all()
return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
@router.get("/moderation")
def list_pending_comments(
limit: int = Query(50, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
query = db.query(Comment).filter(Comment.status == "pending")
total = query.count()
comments = query.order_by(Comment.created_at.asc()).offset(offset).limit(limit).all()
return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
@router.patch("/{comment_id}")
def moderate_comment(
comment_id: int,
data: CommentModerate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
comment = db.get(Comment, comment_id)
if not comment:
raise HTTPException(404, "Comment not found")
comment.status = data.status
db.commit()
return _json(db, comment, current_user)
@router.delete("/{comment_id}", status_code=204)
def delete_comment(
comment_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
comment = db.get(Comment, comment_id)
if not comment:
raise HTTPException(404, "Comment not found")
if comment.user_id != current_user.id and not current_user.is_moderator:
raise HTTPException(403, "Not your comment")
db.delete(comment)
db.commit()

View file

@ -1,4 +1,4 @@
"""Comments moderation and AI authoring endpoints on disposable SQLite; no network/AI."""
"""AI authoring endpoints on disposable SQLite; no network, no model calls."""
import json
import re
import sys
@ -7,10 +7,9 @@ from unittest.mock import Mock, patch
import test_quiz_builder as fixtures
from app.models.article import Article
from app.models.comment import Comment
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question
from app.routers import articles, comments
from app.routers import articles
from app.tasks.quiz_tasks import generate_article_draft, generate_article_cards
from sqlalchemy.orm import sessionmaker
@ -23,13 +22,12 @@ DRAFT_RESPONSE = {
}
class CommentsAiTests(unittest.TestCase):
class ArticleAiTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(articles.router, prefix='/articles')
self.client.app.include_router(comments.router, prefix='/comments')
self.redis = Mock()
self.redis.from_url.return_value = self.redis
self.redis.get.return_value = None
@ -49,49 +47,6 @@ class CommentsAiTests(unittest.TestCase):
def publish(self, article_id):
return self.client.post(f'/articles/{article_id}/publish', json={'published': True})
def test_comment_visibility_moderation_and_bounds(self):
self.bank.user = self.bank.mod
article = self.client.post('/articles/', json={
"title": "Discussed", "slug": "discussed", "content": "Intro", "sections": []}).json()
self.publish(article['id'])
self.bank.user = self.bank.owner
created = self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Owner pending note'})
self.assertEqual(created.status_code, 200, created.text)
self.assertEqual(created.json()['status'], 'pending')
self.assertEqual(created.json()['own'], True)
for payload in ({'article_id': article['id'], 'question_id': 1, 'content': 'x'},
{'article_id': article['id'], 'content': ' '},
{'article_id': article['id'], 'content': 'x' * 2001},
{'article_id': 999, 'content': 'x'},
{'question_id': 999, 'content': 'x'}):
self.assertIn(self.client.post('/comments/', json=payload).status_code, (400, 404, 422), payload)
self.assertEqual(self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Rate limited'}).status_code, 429)
listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
self.assertEqual(listing['total'], 1) # Own pending visible.
self.bank.user = self.bank.peer
listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
self.assertEqual(listing['total'], 0) # Others' pending hidden.
self.assertEqual(self.client.get('/comments/moderation').status_code, 403)
self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'}).status_code, 403)
self.bank.user = self.bank.mod
pending = self.client.get('/comments/moderation').json()
self.assertEqual(pending['total'], 1)
approved = self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'})
self.assertEqual(approved.status_code, 200)
self.assertEqual(approved.json()['author_name'], 'Owner')
self.bank.user = self.bank.peer
listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
self.assertEqual(listing['total'], 1)
self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'rejected'}).status_code, 403)
# Question comments follow bank visibility.
self.bank.user = self.bank.peer
self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Private leak'}).status_code, 404)
self.redis.incr.side_effect = [1]
self.bank.user = self.bank.owner
self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Own question'}).status_code, 200)
self.bank.user = self.bank.mod
self.assertEqual(self.client.delete(f"/comments/{created.json()['id']}").status_code, 204)
def test_ai_endpoints_queue_and_poll(self):
self.bank.user = self.bank.mod
response = self.client.post('/articles/ai-draft', json={'topic': 'Neonatal jaundice', 'instructions': 'Two sections'})

View file

@ -301,6 +301,44 @@ Captured so nothing is lost while the article writing runs.
toggle, "Continue your study", and a study-analysis donut. The current
dashboard becomes this; a separate signed-out landing page comes later.
## Collections, as shown 2026-09-11 (evening)
- [ ] **A collections page.** Favorites and the question libraries in one
place, as AMBOSS has it: a Table view / Card view pair, a sort control
("Last used", descending by default), a count line — "Showing: 1
collection" — and a search by title or date. Each collection is a card
with its name, its item count, whether it is private, and a ⋯ menu.
`/collections/` already backs the folders; this is the page that is
missing.
## Search and AI Mode, as shown 2026-09-11 (evening)
Two screenshots, one flow.
- [ ] **The search overlay.** A panel that opens over whatever you are on, with
a *Search* / *AI Mode* pair of tabs at the top — the same box asks the
corpus or asks the model, and which one is a toggle rather than two
separate destinations. Below the field: SEARCH HISTORY, the previous
queries, and the keys spelled out — `Ctrl+K` open, `↑↓` navigate
suggestions, `Space` use a suggestion, `Enter` submit. Opens from
anywhere with Ctrl+K.
- [ ] **The overlay in AI Mode.** Switching the toggle to *AI Mode* changes
what the same field does: the history and the key hints go, a mode
picker appears beside the tabs ("Learning ⌄" — what the assistant is
being asked to be), a clear button appears in the field once there is
text, and the submit arrow fills in. The panel's own title tracks the
chosen mode.
- [ ] **Submitting from AI Mode lands in the conversation.** The overlay is
only where the question is typed: pressing submit goes to the AI Mode
chat with that question already asked and being answered — not to a
results list, and not back to an empty box.
- [ ] **AI Mode as a page.** A conversation rail on the left grouped by age
("Previous 7 days") with a collapse and a compose control; the empty
state centres one large ask box — "How can PedsHub help you today?" —
with attach and dictate in the box, a tip line under it, and three or
four starting prompts as pills with a SHOW MORE beneath. The existing
`AiModePage` becomes this.
## Taxonomy
- [x] **Systems subsystems** — done 2026-09-10. 69 subsystems created, 305

View file

@ -15,7 +15,6 @@ const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage'))
const QuizPage = lazyPage(() => import('./pages/QuizPage'))
const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage'))
const ResultsPage = lazyPage(() => import('./pages/ResultsPage'))
const AdminPage = lazyPage(() => import('./pages/AdminPage'))
const AccountPage = lazyPage(() => import('./pages/AccountPage'))
const SettingsPage = lazyPage(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage'))
@ -154,7 +153,10 @@ function AppRoutes() {
<Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetailPage />} />
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
<Route path="/admin" element={<AdminPage />} />
{/* One front door. The dashboard's contents are sections of
Settings now; the old address still works for anyone who
bookmarked it. */}
<Route path="/admin" element={<Navigate to="/settings?s=people" replace />} />
</Route>
</Route>

View file

@ -7,11 +7,23 @@ import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard', 'article']
export default function AdminPage() {
/**
* Administration.
*
* Rendered inside Settings rather than as a page of its own: there should be
* one place where the site is configured, not a settings page that links to a
* second settings page. `section` names which part to show and comes from the
* Settings nav. /admin now redirects into Settings, so the tab bar below is
* only reached by rendering this without `embedded` kept because the tabs
* are what the sections are named by, and losing them would lose that.
*/
export default function AdminPage({ section, embedded = false }) {
const { user } = useAuth()
const navigate = useNavigate()
const { dialogProps, openConfirm } = useDialog()
const [tab, setTab] = useState('models')
const [ownTab, setOwnTab] = useState('models')
const tab = section || ownTab
const setTab = setOwnTab
const [users, setUsers] = useState([])
const [models, setModels] = useState([])
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '' })
@ -330,16 +342,20 @@ export default function AdminPage() {
return (
<div>
<Dialog {...dialogProps} />
<div className="card">
<h2>Admin Dashboard</h2>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
{adminTabs.map(({ id, label }) => (
<button key={id} className={`btn ${tab === id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(id)}>
{label}
</button>
))}
{/* Inside Settings the section is already named by the nav, and a second
row of tabs would be a second way to be somewhere. */}
{!embedded && (
<div className="card">
<h2>Admin Dashboard</h2>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
{adminTabs.map(({ id, label }) => (
<button key={id} className={`btn ${tab === id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(id)}>
{label}
</button>
))}
</div>
</div>
</div>
)}
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}

View file

@ -31,7 +31,12 @@
.set-nav button:hover { background: var(--bg); color: var(--text); }
.set-nav button.is-active { background: var(--option-sel-bg); color: var(--primary); font-weight: 650; }
.set-nav-icon { width: 20px; text-align: center; font-size: 1rem; }
.set-nav-rule { margin: 8px 4px; border: 0; border-top: 1px solid var(--border); }
.set-nav-group {
margin: 14px 4px 4px; padding: 0 8px;
font-size: 0.64rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-subtle);
}
.set-nav-group:first-child { margin-top: 0; }
/* ── Panel ────────────────────────────────────────────────────────── */
.set-panel {
@ -108,6 +113,28 @@
.set-nav::-webkit-scrollbar { display: none; }
.set-nav button { white-space: nowrap; border-radius: 999px; border: 1px solid var(--border); }
.set-nav button.is-active { border-color: var(--primary); }
.set-nav-rule { display: none; }
/* The strip scrolls sideways; a heading in the middle of it would read as
an item you cannot press. */
.set-nav-group { display: none; }
.set-nav-icon { display: none; }
}
/* Administration rendered in place
The admin sections were written as their own page and bring their own
cards. Inside a settings panel that would be a box in a box, so the
outermost layer of chrome is taken off and the padding comes from here. */
.set-admin > div > .card,
.set-admin > div > div > .card {
border: 0; border-radius: 0; padding: 0; margin: 0 0 18px; background: none;
}
.set-admin > div > .card:last-child,
.set-admin > div > div > .card:last-child { margin-bottom: 0; }
.set-admin h2 {
margin: 0 0 10px;
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.set-admin h3 { font-size: 0.95rem; }
.set-admin table { width: 100%; }
/* Wide tables scroll inside themselves rather than widening the page. */
.set-admin .table-wrap, .set-admin .admin-table-wrap { overflow-x: auto; }

View file

@ -1,10 +1,15 @@
import { Fragment, useState, useEffect } from 'react'
import { Fragment, Suspense, useState, useEffect } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../context/ThemeContext'
import api from '../api/client'
import ExamSwitcher from '../components/ExamSwitcher'
import SitePolicy from '../components/SitePolicy'
import lazyPage from '../utils/lazyPage'
// Only an administrator ever renders this, and it is the largest thing on the
// page so it is not in everyone else's download.
const AdminPage = lazyPage(() => import('./AdminPage'))
import './SettingsPage.css'
function Section({ title, description, children }) {
@ -213,12 +218,16 @@ function NextcloudSection() {
)
}
function AdminSection() {
/**
* The places that are their own pages for good reason a taxonomy tree, an
* editorial queue, a job log. Everything that is a *setting* now lives in this
* page's own sections rather than behind a link to a second dashboard.
*/
function ToolsSection() {
return (
<Section title="Administration" description="Tools for moderators and administrators.">
<Section title="Tools" description="Editing surfaces that have a page of their own.">
<div className="set-cards">
{[
{ to: '/admin', icon: '⚙️', label: 'Admin dashboard', desc: 'Models, users and settings' },
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
{ to: '/categories', icon: '🗂️', label: 'Taxonomy', desc: 'Topics, systems, symptoms, diseases' },
{ to: '/editorial', icon: '✍️', label: 'Editorial', desc: 'Article drafts and references' },
@ -237,6 +246,19 @@ function AdminSection() {
)
}
/** One of the admin dashboard's sections, shown in place rather than linked to. */
function AdminSection({ title, description, section }) {
return (
<Section title={title} description={description}>
<div className="set-admin">
<Suspense fallback={<div className="loading"><div className="spinner" /></div>}>
<AdminPage embedded section={section} />
</Suspense>
</div>
</Section>
)
}
const DOCS_PREVIEW = 5
function DocumentsSection() {
@ -369,14 +391,6 @@ function DataSection() {
)
}
/**
* Settings as a set of places, each with its own address.
*
* It was one 600px column holding the account form, the theme picker, a
* Nextcloud integration, a document list and an admin link grid, in that
* order, with no way to link to any of it. The section now lives in the URL,
* so "change your password" is a link and Back works.
*/
/** Who may register, and whether sessions can be shared. Administrators only. */
function SitePolicySection() {
return (
@ -387,43 +401,77 @@ function SitePolicySection() {
)
}
/**
* Settings as a set of places, each with its own address.
*
* It was one 600px column holding the account form, the theme picker, a
* Nextcloud integration, a document list and a grid of links one of which
* went to a second dashboard with a second row of tabs and a second visual
* language. There is one place to configure the site now: the admin sections
* are rendered here, under headings that say who they are for, and the section
* lives in the URL so "change your password" is a link and Back works.
*/
export default function SettingsPage() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const isModerator = isAdmin || user?.role === 'moderator'
const [params, setParams] = useSearchParams()
// group: the heading this sits under. Sections are listed in the order a
// person needs them themselves first, the site last.
const sections = [
{ key: 'account', icon: '👤', label: 'Account', render: () => <ProfileSection user={user} /> },
{ key: 'study', icon: '🎯', label: 'Studying for', render: () => <StudySection /> },
{ key: 'appearance', icon: '🎨', label: 'Appearance', render: () => <AppearanceSection /> },
{ key: 'data', icon: '🗄️', label: 'Your data', render: () => <DataSection /> },
{ key: 'account', group: 'You', icon: '👤', label: 'Account',
render: () => <ProfileSection user={user} /> },
{ key: 'study', group: 'You', icon: '🎯', label: 'Studying for',
render: () => <StudySection /> },
{ key: 'appearance', group: 'You', icon: '🎨', label: 'Appearance',
render: () => <AppearanceSection /> },
{ key: 'data', group: 'You', icon: '🗄️', label: 'Your data',
render: () => <DataSection /> },
...(isModerator ? [
{ key: 'library', icon: '📚', label: 'Documents', divider: true,
{ key: 'library', group: 'Content', icon: '📚', label: 'Documents',
render: () => <><DocumentsSection /><NextcloudSection /></> },
{ key: 'admin', icon: '🛠️', label: 'Administration', render: () => <AdminSection /> },
{ key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools',
render: () => <ToolsSection /> },
] : []),
...(isAdmin ? [
{ key: 'policy', icon: '🔒', label: 'Site policy', render: () => <SitePolicySection /> },
{ key: 'policy', group: 'The site', icon: '🔒', label: 'Access and joining',
render: () => <SitePolicySection /> },
{ key: 'people', group: 'The site', icon: '👥', label: 'People',
render: () => <AdminSection section="users" title="People"
description="Everyone with an account, and what each of them may do." /> },
{ key: 'models', group: 'The site', icon: '🧠', label: 'AI models',
render: () => <AdminSection section="models" title="AI models"
description="Which model answers which kind of request, and what happens when one is unavailable." /> },
{ key: 'safety', group: 'The site', icon: '🛡️', label: 'Safety',
render: () => <AdminSection section="safety" title="Safety"
description="Limits on what the assistant will do, and what it refuses." /> },
{ key: 'search', group: 'The site', icon: '🔎', label: 'Search',
render: () => <AdminSection section="settings" title="Search and sign-up"
description="The embedding model behind semantic search, and how accounts are created." /> },
] : []),
]
const requested = params.get('s')
const active = sections.find(section => section.key === requested) || sections[0]
// Only worth heading the groups when there is more than one of them.
const grouped = new Set(sections.map(section => section.group)).size > 1
return (
<div className="set-layout">
<div className="set-head">
<h1>Settings</h1>
<p>Your account, how the site looks, and what it shows you.</p>
<p>Your account, how the site looks, and if it is yours to set how the site behaves.</p>
</div>
<nav className="set-nav" aria-label="Settings sections">
{/* Buttons are direct children so the list can become a scrolling
strip on a narrow screen without a wrapper in the way. */}
{sections.map(section => (
{sections.map((section, i) => (
<Fragment key={section.key}>
{section.divider && <hr className="set-nav-rule" />}
{grouped && section.group !== sections[i - 1]?.group && (
<p className="set-nav-group">{section.group}</p>
)}
<button type="button"
className={section.key === active.key ? 'is-active' : undefined}
aria-current={section.key === active.key ? 'page' : undefined}

View file

@ -52,15 +52,31 @@ describe('settings', () => {
it('keeps moderator sections away from a learner', async () => {
mount()
await screen.findByRole('heading', { name: 'Account' })
expect(screen.queryByRole('button', { name: /Administration/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Tools/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Documents/ })).not.toBeInTheDocument()
})
it('offers them to a moderator', async () => {
it('offers them to a moderator, but not the ones that are the site itself', async () => {
currentUser = { ...currentUser, role: 'moderator' }
mount()
await screen.findByRole('heading', { name: 'Account' })
expect(screen.getByRole('button', { name: /Administration/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Tools/ })).toBeInTheDocument()
// Who may join and which model answers are an administrator's, not a
// moderator's.
expect(screen.queryByRole('button', { name: /AI models/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Access and joining/ })).not.toBeInTheDocument()
})
it('holds administration itself rather than linking to a second dashboard', async () => {
currentUser = { ...currentUser, role: 'admin' }
mount()
await screen.findByRole('heading', { name: 'Account' })
// The old page offered a grid of links, one of which went to /admin and
// its own row of tabs. These are sections of this page now.
for (const label of ['People', 'AI models', 'Safety', 'Search']) {
expect(screen.getByRole('button', { name: new RegExp(label) })).toBeInTheDocument()
}
expect(screen.queryByRole('link', { name: /Admin dashboard/ })).toBeNull()
})
it('carries the exam objective, which was only reachable from the navbar', async () => {