diff --git a/package-lock.json b/package-lock.json index ebf3311..542c560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.24.0", + "version": "2.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.24.0", + "version": "2.25.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index c346944..2e0fac3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.24.0", + "version": "2.25.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/data.js b/server/data.js index a17a7c9..0462789 100644 --- a/server/data.js +++ b/server/data.js @@ -1394,6 +1394,8 @@ export function sanitizeStudyUsers(value) { studyRemindersEnabled, enrolledStudySlugs, completedStudySlugs, + // Only the admin panel can set this (see PATCH /api/admin/study-users/:id) + adminShortcut: item?.adminShortcut === true, currentStreak: Number.isFinite(item?.currentStreak) && item.currentStreak > 0 ? Math.floor(item.currentStreak) : 0, longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0, lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null, diff --git a/server/routes/admin-assets.js b/server/routes/admin-assets.js index 2f35c23..8ecc83a 100644 --- a/server/routes/admin-assets.js +++ b/server/routes/admin-assets.js @@ -239,6 +239,7 @@ export function register(app) { currentStreak: user.currentStreak ?? 0, longestStreak: user.longestStreak ?? 0, reengagementSentAt: user.reengagementSentAt ?? {}, + adminShortcut: user.adminShortcut === true, } })) @@ -282,12 +283,16 @@ export function register(app) { const user = state.studyUsers.find(u => u.id === req.params.id) if (!user) { res.status(404).json({ message: 'User not found.' }); return } - const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {} + const { displayName, newPassword, addEnrollment, removeEnrollment, adminShortcut } = req.body ?? {} if (typeof displayName === 'string') { user.displayName = displayName.trim().slice(0, 80) } + if (typeof adminShortcut === 'boolean') { + user.adminShortcut = adminShortcut + } + if (typeof newPassword === 'string') { if (newPassword.length < 8 || newPassword.length > 200) { res.status(400).json({ message: 'Password must be 8–200 characters.' }); return @@ -314,7 +319,7 @@ export function register(app) { user.updatedAt = new Date().toISOString() queueStudyUsersWrite() - res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs }) + res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs, adminShortcut: user.adminShortcut === true }) }) app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => { diff --git a/server/routes/study-auth.js b/server/routes/study-auth.js index ea87083..8a09187 100644 --- a/server/routes/study-auth.js +++ b/server/routes/study-auth.js @@ -70,6 +70,9 @@ export function register(app) { subscribeNewsletter: user?.subscribeNewsletter !== false, studyRemindersEnabled: user?.studyRemindersEnabled === true, avatarUrl: user ? getStudyAvatarUrl(user) : '', + // Shows an Admin link in the account menu. Only the admin panel can turn this on, and the link + // still lands on the admin sign-in, so it grants no access by itself. + adminShortcut: user?.adminShortcut === true, // Studies the student can open: enrolled ones plus any they have finished enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [], completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [], diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 841e838..dc72156 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -120,6 +120,7 @@ interface StudyUserRecord { reminderFrequency: 'immediate' | 'weekly' remindersPausedUntil: string | null reengagementSentAt: Record + adminShortcut?: boolean } function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { @@ -145,7 +146,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { .catch(() => setLoading(false)) }, []) - async function patchUser(id: string, body: Record) { + async function patchUser(id: string, body: Record) { const res = await fetch(`/api/admin/study-users/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -165,6 +166,14 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } + async function handleToggleAdminShortcut(user: StudyUserRecord, on: boolean) { + try { + await patchUser(user.id, { adminShortcut: on }) + setUsers(prev => prev.map(u => (u.id === user.id ? { ...u, adminShortcut: on } : u))) + flashMsg(user.id, on ? 'Admin shortcut turned on.' : 'Admin shortcut turned off.') + } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } + } + async function handleResetPassword(user: StudyUserRecord) { const pw = newPassword[user.id] ?? '' if (pw.length < 8) { flashMsg(user.id, 'Password must be at least 8 characters.'); return } @@ -265,6 +274,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { {user.displayName && {user.username}}
+ {user.adminShortcut && Admin shortcut} {user.enrolledStudies.length} enrolled {user.noteCount} notes {(user.currentStreak ?? 0) > 0 && {user.currentStreak} 🔥} @@ -304,6 +314,16 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
+ {/* Admin shortcut */} +
+

Admin Shortcut

+ +

Only accounts with this turned on see the link. It only takes them to the admin sign-in page; signing in still needs the admin password and code.

+
+ {/* Password Reset */}

Reset Password

diff --git a/src/components/HeaderAccount.tsx b/src/components/HeaderAccount.tsx index 41b1f0d..c3880b5 100644 --- a/src/components/HeaderAccount.tsx +++ b/src/components/HeaderAccount.tsx @@ -6,6 +6,7 @@ interface AccountStatus { username: string displayName?: string avatarUrl?: string + adminShortcut?: boolean } // Shared across header instances so moving between pages doesn't flash the signed-out icon @@ -87,6 +88,9 @@ export function HeaderAccount() {

Signed in as {name}

setOpen(false)}>My Account + {status.adminShortcut && ( + setOpen(false)}>Admin + )}
)}