diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 889471f..6a95b75 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -396,6 +396,18 @@ def list_quiz_sessions( rows.sort(key=lambda r: (r["last_activity"] or ""), reverse=True) return rows + +@router.get("/share-policy") +def share_policy(current_user: User = Depends(get_current_user)): + """Whether this site lets a learner hand a session to somebody. + + Asked before the share dialog offers to make a link, so a switch an + administrator has thrown reads as "not offered" rather than as a button + that fails when pressed. + """ + return {"enabled": site_settings.get_flag("sharing_enabled")} + + @router.get("/", response_model=list[QuizResponse]) def list_quizzes( db: Session = Depends(get_db), diff --git a/frontend/src/components/ShareSession.css b/frontend/src/components/ShareSession.css new file mode 100644 index 0000000..e42c358 --- /dev/null +++ b/frontend/src/components/ShareSession.css @@ -0,0 +1,142 @@ +.sh-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(15, 23, 42, 0.55); +} + +.sh { + width: min(480px, 100%); + max-height: calc(100dvh - 40px); + overflow-y: auto; + padding: 20px; + border-radius: 14px; + background: var(--surface, #fff); + box-shadow: 0 18px 48px rgba(15, 23, 42, 0.28); +} + +.sh-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.sh-head h2 { + margin: 0; + font-size: 1.05rem; +} + +.sh-head button { + border: 0; + background: none; + padding: 4px 8px; + font-size: 1rem; + line-height: 1; + cursor: pointer; + color: var(--text-muted, #64748b); +} + +.sh-lead { + margin: 6px 0 14px; + font-size: 0.85rem; + color: var(--text-muted, #64748b); +} + +/* The session itself, so what is being handed over is visible first. */ +.sh-preview { + padding: 12px 14px; + border: 1px solid var(--border, #e2e8f0); + border-radius: 10px; + background: var(--surface-alt, #f8fafc); +} + +.sh-preview-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.sh-preview-head span { + flex: none; + font-size: 0.78rem; + color: var(--text-muted, #64748b); +} + +.sh-preview-stem { + margin: 8px 0 0; + font-size: 0.82rem; + line-height: 1.5; + color: var(--text-muted, #64748b); +} + +.sh-link { + display: flex; + gap: 8px; + margin-top: 14px; +} + +.sh-link input { + flex: 1; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border, #e2e8f0); + border-radius: 8px; + font-size: 0.82rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + background: var(--surface-alt, #f8fafc); +} + +.sh-targets { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.sh-target { + flex: 1 1 auto; + min-width: 96px; + padding: 9px 12px; + border: 1px solid var(--border, #e2e8f0); + border-radius: 8px; + text-align: center; + font-size: 0.82rem; + font-weight: 600; + text-decoration: none; + color: var(--text, #0f172a); + background: var(--surface, #fff); +} + +.sh-target:hover { border-color: var(--primary, #2563eb); color: var(--primary, #2563eb); } + +.sh-revoke { margin-top: 14px; } + +.sh-none { + margin: 14px 0 0; + font-size: 0.85rem; + color: var(--text-muted, #64748b); +} + +.sh-error { + margin: 12px 0 0; + font-size: 0.82rem; + color: var(--danger, #dc2626); +} + +/* On a phone the dialog is the page rather than a card floating on it. */ +@media (max-width: 520px) { + .sh-overlay { padding: 0; align-items: flex-end; } + .sh { + width: 100%; + max-height: 88dvh; + border-radius: 14px 14px 0 0; + padding-bottom: max(20px, env(safe-area-inset-bottom)); + } + .sh-link { flex-direction: column; } +} diff --git a/frontend/src/components/ShareSession.jsx b/frontend/src/components/ShareSession.jsx new file mode 100644 index 0000000..2f3545d --- /dev/null +++ b/frontend/src/components/ShareSession.jsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react' +import api from '../api/client' +import './ShareSession.css' + +const apiError = (err, fallback) => { + const detail = err?.response?.data?.detail + return typeof detail === 'string' ? detail : fallback +} + +/** + * Handing a session to someone else. + * + * A copy button on its own tells you nothing about what you are about to send. + * This shows the session and the question it opens on, so the thing being + * shared is visible before it is passed on — and offers the places people + * actually send a link, rather than only the clipboard. + * + * An administrator can turn sharing off for the whole site. A link already + * issued keeps working; this simply refuses to make a new one, and says so. + */ +export default function ShareSession({ quiz, firstQuestion, onShareChanged, canManage }) { + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [copied, setCopied] = useState(false) + // Null until asked. Treated as allowed while unknown so a slow answer never + // hides a link that already exists. + const [allowed, setAllowed] = useState(null) + + const token = quiz?.share_token + const url = token ? `${window.location.origin}/share/${token}` : '' + + useEffect(() => { + if (!open) return undefined + setError('') + let live = true + api.get('/quizzes/share-policy') + .then(res => { if (live) setAllowed(res.data?.enabled !== false) }) + .catch(() => { if (live) setAllowed(true) }) + const onKey = e => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', onKey) + return () => { + live = false + document.removeEventListener('keydown', onKey) + } + }, [open]) + + const enable = async () => { + setBusy(true); setError('') + try { + const res = await api.post(`/quizzes/${quiz.id}/share-link`) + onShareChanged?.(res.data.token) + } catch (err) { setError(apiError(err, 'Could not create a link')) } + finally { setBusy(false) } + } + + const revoke = async () => { + setBusy(true); setError('') + try { + await api.delete(`/quizzes/${quiz.id}/share-link`) + onShareChanged?.(null) + } catch (err) { setError(apiError(err, 'Could not revoke sharing')) } + finally { setBusy(false) } + } + + const copy = () => { + navigator.clipboard?.writeText(url) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const text = encodeURIComponent(`Try this: ${quiz?.title || 'a question session'}`) + const link = encodeURIComponent(url) + const targets = [ + { key: 'email', label: 'Email', href: `mailto:?subject=${text}&body=${link}` }, + { key: 'whatsapp', label: 'WhatsApp', href: `https://wa.me/?text=${text}%20${link}` }, + { key: 'telegram', label: 'Telegram', href: `https://t.me/share/url?url=${link}&text=${text}` }, + ] + + return ( + <> + + + {open && ( +
e.target === e.currentTarget && setOpen(false)}> +
+
+

Share this session

+ +
+ +

+ Anyone with the link can sit these questions. They do not need an account. +

+ + {/* What is actually being handed over. */} +
+
+ {quiz?.title} + {quiz?.questions_count || 0} questions +
+ {firstQuestion &&

{firstQuestion.slice(0, 180)}…

} +
+ + {token ? ( + <> +
+ e.target.select()} /> + +
+
+ {targets.map(target => ( + {target.label} + ))} +
+ {canManage && ( + + )} + + ) : canManage && allowed !== false ? ( + + ) : allowed === false ? ( +

Sharing sessions is turned off for this site.

+ ) : ( +

This session has not been shared.

+ )} + + {error &&

{error}

} +
+
+ )} + + ) +} diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 63ac1fd..d6b461d 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -9,6 +9,7 @@ import api from '../api/client' import useMediaQuery from '../hooks/useMediaQuery' import FigureStrip from '../components/FigureStrip' import FeedbackForm from '../components/FeedbackForm' +import ShareSession from '../components/ShareSession' import '../components/Feedback.css' import QuizTools, { QuizDialog } from '../components/QuizTools' import './QuizPlayer.css' @@ -448,7 +449,6 @@ export default function QuizPage() { const [note, setNote] = useState('') const [noteSaved, setNoteSaved] = useState(true) const [folders, setFolders] = useState([]) - const [shareUrl, setShareUrl] = useState('') const [showReview, setShowReview] = useState(false) const [responseStats, setResponseStats] = useState(null) const [statsError, setStatsError] = useState('') @@ -856,10 +856,6 @@ const timerStarted = timeLeft !== null api.get('/collections/').then(res => setFolders(res.data || [])).catch(() => setFolders([])) }, []) - useEffect(() => { - setShareUrl(quiz?.share_token ? `${window.location.origin}/share/${quiz.share_token}` : '') - }, [quiz?.share_token]) - const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) // Reset one question rather than the whole attempt: a misclick should cost @@ -1351,7 +1347,8 @@ const timerStarted = timeLeft !== null onClick={() => setPanel(p => (p === 'save' ? null : 'save'))}> ⊞ Save to a folder - setQuiz(q => ({ ...q, share_token: token }))} />
⚑ Give feedback @@ -1444,15 +1441,6 @@ const timerStarted = timeLeft !== null })} )} - {shareUrl && ( -
-

Share this session

- e.target.select()} /> - -
- )} )} diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 5b9b2bb..26b7dbb 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -439,7 +439,7 @@ describe('quiz player', () => { })) }) - it('makes a quiz shareable and copies the link without showing it', async () => { + it('shows what is being shared, then makes the link', async () => { const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) } Object.defineProperty(navigator, 'clipboard', { value: clipboard, configurable: true }) const originalPost = api.post.getMockImplementation() @@ -453,12 +453,36 @@ describe('quiz player', () => { // Sharing folded away behind the question bar's more-menu, alongside // saving and giving feedback — occasional actions, not part of every read. await userEvent.click(await screen.findByRole('button', { name: 'More options' })) - await userEvent.click(screen.getByRole('button', { name: 'Make shareable' })) - await screen.findByRole('button', { name: 'Copy share link' }) + await userEvent.click(screen.getByRole('button', { name: /Share this session/ })) + // The dialog names the session and shows the question it opens on, so the + // thing being handed over is visible before it is handed over. + const dialog = await screen.findByRole('dialog', { name: 'Share this session' }) + expect(within(dialog).getByText('Personal test')).toBeInTheDocument() + expect(within(dialog).getByText(/Full first clinical question/)).toBeInTheDocument() + + await userEvent.click(within(dialog).getByRole('button', { name: 'Create a share link' })) + await within(dialog).findByRole('button', { name: 'Copy' }) expect(api.post).toHaveBeenCalledWith('/quizzes/10/share-link') - await userEvent.click(screen.getByRole('button', { name: 'Copy share link' })) + // The link is shown, not hidden: you should be able to read what you send. + expect(within(dialog).getByLabelText('Share link').value).toContain('/share/tok-1') + await userEvent.click(within(dialog).getByRole('button', { name: 'Copy' })) expect(clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('/share/tok-1')) - expect(screen.getByText('✓ Link copied')).toBeInTheDocument() - expect(screen.queryByText(/share\/tok-1/)).not.toBeInTheDocument() + expect(within(dialog).getByRole('link', { name: 'Email' })).toBeInTheDocument() + }) + + it('offers no new link when an administrator has turned sharing off', async () => { + const originalGet = api.get.getMockImplementation() + api.get.mockImplementation((url, ...args) => { + if (url === '/quizzes/share-policy') return Promise.resolve({ data: { enabled: false } }) + return originalGet(url, ...args) + }) + mount() + await userEvent.click(await screen.findByRole('button', { name: 'Start session' })) + await findStem('Full first clinical question.') + await userEvent.click(await screen.findByRole('button', { name: 'More options' })) + await userEvent.click(screen.getByRole('button', { name: /Share this session/ })) + const dialog = await screen.findByRole('dialog', { name: 'Share this session' }) + expect(await within(dialog).findByText(/turned off for this site/)).toBeInTheDocument() + expect(within(dialog).queryByRole('button', { name: 'Create a share link' })).toBeNull() }) })