feat(auth): make upload review a separate grant instead of an admin privilege

Reviewing the upload queue is now a per-user flag (reviews_uploads), not part
of the admin role: several people can hold it, a moderator can have it, and an
admin may not. Only admins grant or revoke it; existing admins keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-21 10:07:05 +03:00
1 parent 066ba24834
commit 74da457362
27 files changed
+503 -319

No files matched your search

+3 -1
View File
@@ -226,7 +226,9 @@ const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/upl
const uploadQueueRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/uploads',
beforeLoad: adminOnly,
beforeLoad: ({ context }) => {
if (!context.user.reviews_uploads) throw redirect({ to: '/' });
},
component: UploadQueuePage,
});
+20 -1
View File
@@ -132,6 +132,11 @@ export function UsersPage() {
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
});
if (me.role === 'admin')
items.push({
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
});
if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items;
@@ -206,6 +211,7 @@ export function UsersPage() {
</td>
<td>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td>
<td>
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
@@ -285,12 +291,18 @@ function CreateUserDialog({
const [username, setUsername] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState<Role>('user');
const [reviews, setReviews] = useState(false);
const create = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/admin/users', {
body: { username: username.trim(), display_name: name.trim() || undefined, role },
body: {
username: username.trim(),
display_name: name.trim() || undefined,
role,
reviews_uploads: actorRole === 'admin' && reviews ? true : undefined,
},
}),
),
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
@@ -342,6 +354,13 @@ function CreateUserDialog({
</select>
</label>
)}
{actorRole === 'admin' && (
<label className={s.sessions} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={reviews} onChange={(e) => setReviews(e.target.checked)} />
<span>{t('create.review_uploads')}</span>
</label>
)}
{actorRole === 'admin' && <p className={s.note}>{t('create.review_uploads_hint')}</p>}
<p className={s.note}>{t('create.password_note')}</p>
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
</form>
+17
View File
@@ -56,6 +56,7 @@ export function makeUser(username: string, role: Role, extra: Partial<FakeUser>
is_blocked: false,
must_change_password: false,
must_change_credentials: false,
reviews_uploads: false,
created_at: '2026-01-01T00:00:00Z',
last_login_at: null,
password: `${username}-password`,
@@ -80,6 +81,7 @@ export function fakeApi(initial: FakeUser[]) {
username: u.username,
display_name: u.display_name,
role: u.role,
reviews_uploads: u.reviews_uploads,
locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password,
@@ -179,6 +181,7 @@ export function fakeApi(initial: FakeUser[]) {
display_name: (body?.display_name as string) ?? null,
must_change_password: true,
password: 'Temp-Pass-123',
reviews_uploads: body?.reviews_uploads === true && user.role === 'admin',
});
users.set(u.id, u);
return json({ user: admin(u), temporary_password: u.password }, 201);
@@ -235,6 +238,20 @@ export function fakeApi(initial: FakeUser[]) {
}
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path))
return new Response(null, { status: 204 });
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
if (userPath && req.method === 'PATCH') {
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
const target = users.get(userPath[1]!);
if (!target) return problem(404, 'common.not_found');
if (typeof body?.reviews_uploads === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.reviews_uploads = body.reviews_uploads;
}
if (typeof body?.role === 'string' && user.role === 'admin') target.role = body.role as Role;
if (typeof body?.is_blocked === 'boolean') target.is_blocked = body.is_blocked;
users.set(target.id, target);
return json(admin(target));
}
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
if (reactionPath) {
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
+21 -9
View File
@@ -153,7 +153,8 @@ function Shell() {
const searchAt = user.ui_prefs.search_position ?? 'center';
const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse === true} />;
const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin');
const reviews = user.reviews_uploads;
const pending = usePendingUploads(reviews);
const unseen = useUnseenUploads(staff);
return (
@@ -207,8 +208,17 @@ function Shell() {
count={counts.dislikes}
/>
</NavSection>
{!staff && reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{staff && (
<NavSection id="admin" title={t('nav.admin')} badge={pending} defaultFolded>
<NavSection id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
@@ -216,15 +226,17 @@ function Shell() {
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
{reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{user.role === 'admin' && (
<>
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>