Admin shortcut in the account menu, switched on per study user from /admin; v2.25.0
Publish Container / build (push) Has been cancelled
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
853e758bab
commit
d0741d628e
7 files changed
+40
-6
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.24.0",
|
"version": "2.25.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.24.0",
|
"version": "2.25.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1135.0",
|
"@aws-sdk/client-s3": "^3.1135.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.24.0",
|
"version": "2.25.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1394,6 +1394,8 @@ export function sanitizeStudyUsers(value) {
|
|||||||
studyRemindersEnabled,
|
studyRemindersEnabled,
|
||||||
enrolledStudySlugs,
|
enrolledStudySlugs,
|
||||||
completedStudySlugs,
|
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,
|
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,
|
longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0,
|
||||||
lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null,
|
lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null,
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export function register(app) {
|
|||||||
currentStreak: user.currentStreak ?? 0,
|
currentStreak: user.currentStreak ?? 0,
|
||||||
longestStreak: user.longestStreak ?? 0,
|
longestStreak: user.longestStreak ?? 0,
|
||||||
reengagementSentAt: user.reengagementSentAt ?? {},
|
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)
|
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
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') {
|
if (typeof displayName === 'string') {
|
||||||
user.displayName = displayName.trim().slice(0, 80)
|
user.displayName = displayName.trim().slice(0, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof adminShortcut === 'boolean') {
|
||||||
|
user.adminShortcut = adminShortcut
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof newPassword === 'string') {
|
if (typeof newPassword === 'string') {
|
||||||
if (newPassword.length < 8 || newPassword.length > 200) {
|
if (newPassword.length < 8 || newPassword.length > 200) {
|
||||||
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
|
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()
|
user.updatedAt = new Date().toISOString()
|
||||||
queueStudyUsersWrite()
|
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) => {
|
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export function register(app) {
|
|||||||
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
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
|
// Studies the student can open: enrolled ones plus any they have finished
|
||||||
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
|
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
|
||||||
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
|
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
|
||||||
|
|||||||
+21
-1
@@ -120,6 +120,7 @@ interface StudyUserRecord {
|
|||||||
reminderFrequency: 'immediate' | 'weekly'
|
reminderFrequency: 'immediate' | 'weekly'
|
||||||
remindersPausedUntil: string | null
|
remindersPausedUntil: string | null
|
||||||
reengagementSentAt: Record<string, string>
|
reengagementSentAt: Record<string, string>
|
||||||
|
adminShortcut?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||||||
@@ -145,7 +146,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
|||||||
.catch(() => setLoading(false))
|
.catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function patchUser(id: string, body: Record<string, string>) {
|
async function patchUser(id: string, body: Record<string, string | boolean>) {
|
||||||
const res = await fetch(`/api/admin/study-users/${id}`, {
|
const res = await fetch(`/api/admin/study-users/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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') }
|
} 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) {
|
async function handleResetPassword(user: StudyUserRecord) {
|
||||||
const pw = newPassword[user.id] ?? ''
|
const pw = newPassword[user.id] ?? ''
|
||||||
if (pw.length < 8) { flashMsg(user.id, 'Password must be at least 8 characters.'); return }
|
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 && <span className="admin-study-user-email">{user.username}</span>}
|
{user.displayName && <span className="admin-study-user-email">{user.username}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-study-user-header-right">
|
<div className="admin-study-user-header-right">
|
||||||
|
{user.adminShortcut && <span className="admin-study-user-pill">Admin shortcut</span>}
|
||||||
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
|
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
|
||||||
<span className="admin-study-user-pill">{user.noteCount} notes</span>
|
<span className="admin-study-user-pill">{user.noteCount} notes</span>
|
||||||
{(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>}
|
{(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>}
|
||||||
@@ -304,6 +314,16 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Admin shortcut */}
|
||||||
|
<div className="admin-study-user-section">
|
||||||
|
<h4>Admin Shortcut</h4>
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={user.adminShortcut === true} onChange={e => handleToggleAdminShortcut(user, e.target.checked)} />
|
||||||
|
Show an "Admin" link in this account's menu on the site
|
||||||
|
</label>
|
||||||
|
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>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.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Password Reset */}
|
{/* Password Reset */}
|
||||||
<div className="admin-study-user-section">
|
<div className="admin-study-user-section">
|
||||||
<h4>Reset Password</h4>
|
<h4>Reset Password</h4>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface AccountStatus {
|
|||||||
username: string
|
username: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
adminShortcut?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared across header instances so moving between pages doesn't flash the signed-out icon
|
// Shared across header instances so moving between pages doesn't flash the signed-out icon
|
||||||
@@ -87,6 +88,9 @@ export function HeaderAccount() {
|
|||||||
<div className="header-account-menu" role="menu">
|
<div className="header-account-menu" role="menu">
|
||||||
<p className="header-account-name">Signed in as <strong>{name}</strong></p>
|
<p className="header-account-name">Signed in as <strong>{name}</strong></p>
|
||||||
<Link to="/study/account" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>My Account</Link>
|
<Link to="/study/account" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>My Account</Link>
|
||||||
|
{status.adminShortcut && (
|
||||||
|
<Link to="/admin" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>Admin</Link>
|
||||||
|
)}
|
||||||
<button type="button" role="menuitem" className="header-account-item" onClick={signOut}>Sign out</button>
|
<button type="button" role="menuitem" className="header-account-item" onClick={signOut}>Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user