diff --git a/src/components/pages/serverActions/index.tsx b/src/components/pages/serverActions/index.tsx index 80522e70..f1885d7b 100644 --- a/src/components/pages/serverActions/index.tsx +++ b/src/components/pages/serverActions/index.tsx @@ -1,4 +1,5 @@ import { Group, Paper, Stack, Text, Title } from '@mantine/core'; +import { useUserStore } from '@/lib/client/store/user'; import ClearTempButton from './actions/ClearTempButton'; import ClearZerosButton from './actions/ClearZerosButton'; import GenThumbsButton from './actions/GenThumbsButton'; @@ -10,6 +11,7 @@ const ACTIONS = [ name: 'Import/Export Data', desc: 'Allows you to import or export server data and configurations.', Component: ImportExport, + superAdminOnly: true, }, { name: 'Clear Temporary Files', @@ -34,6 +36,9 @@ const ACTIONS = [ ]; export default function DashboardServerActions() { + const user = useUserStore((state) => state.user); + const actions = ACTIONS.filter((action) => !action.superAdminOnly || user?.role === 'SUPERADMIN'); + return ( <> @@ -43,7 +48,7 @@ export default function DashboardServerActions() { Useful tools and scripts for server management. - {ACTIONS.map(({ name, desc, Component }) => ( + {actions.map(({ name, desc, Component }) => ( diff --git a/src/components/pages/users/EditUserModal.tsx b/src/components/pages/users/EditUserModal.tsx index a9e8ca16..50a4ce86 100644 --- a/src/components/pages/users/EditUserModal.tsx +++ b/src/components/pages/users/EditUserModal.tsx @@ -1,7 +1,7 @@ import { Response } from '@/lib/api/response'; import { readToDataURL } from '@/lib/base64'; import { bytes } from '@/lib/bytes'; -import { User } from '@/lib/db/models/user'; +import { LimitedUser } from '@/lib/db/models/user'; import { fetchApi } from '@/lib/fetchApi'; import { canInteract } from '@/lib/role'; import { useUserStore } from '@/lib/client/store/user'; @@ -31,7 +31,7 @@ export default function EditUserModal({ opened, onClose, }: { - user?: User | null; + user?: LimitedUser | null; opened: boolean; onClose: () => void; }) { diff --git a/src/components/pages/users/UserCard.tsx b/src/components/pages/users/UserCard.tsx index 3d91cb5b..7aefe611 100644 --- a/src/components/pages/users/UserCard.tsx +++ b/src/components/pages/users/UserCard.tsx @@ -1,4 +1,4 @@ -import { User } from '@/lib/db/models/user'; +import { LimitedUser } from '@/lib/db/models/user'; import { ActionIcon, Avatar, Card, Group, Menu, Stack, Text } from '@mantine/core'; import { useUserStore } from '@/lib/client/store/user'; import { IconDots, IconFiles, IconTrashFilled, IconUserEdit } from '@tabler/icons-react'; @@ -9,7 +9,7 @@ import RelativeDate from '@/components/RelativeDate'; import { canInteract, isAdministrator, roleName } from '@/lib/role'; import { Link } from 'react-router-dom'; -export default function UserCard({ user }: { user: User }) { +export default function UserCard({ user }: { user: LimitedUser }) { const currentUser = useUserStore((state) => state.user); const [opened, setOpen] = useState(false); diff --git a/src/components/pages/users/actions.tsx b/src/components/pages/users/actions.tsx index 024dd27d..385922d6 100644 --- a/src/components/pages/users/actions.tsx +++ b/src/components/pages/users/actions.tsx @@ -1,12 +1,12 @@ import { Response } from '@/lib/api/response'; -import { User } from '@/lib/db/models/user'; +import { LimitedUser } from '@/lib/db/models/user'; import { fetchApi } from '@/lib/fetchApi'; import { modals } from '@mantine/modals'; import { notifications } from '@mantine/notifications'; import { IconUserCancel, IconUserMinus } from '@tabler/icons-react'; import { mutate } from 'swr'; -export async function deleteUser(user: User) { +export async function deleteUser(user: LimitedUser) { modals.openConfirmModal({ centered: true, title: `Delete ${user.username}?`, @@ -33,7 +33,7 @@ export async function deleteUser(user: User) { }); } -async function handleDeleteUser(user: User, deleteFiles: boolean = false) { +async function handleDeleteUser(user: LimitedUser, deleteFiles: boolean = false) { const { data, error } = await fetchApi(`/api/users/${user.id}`, 'DELETE', { delete: deleteFiles, }); diff --git a/src/components/pages/users/index.tsx b/src/components/pages/users/index.tsx index 99490d91..f8a46cc0 100644 --- a/src/components/pages/users/index.tsx +++ b/src/components/pages/users/index.tsx @@ -1,7 +1,6 @@ import GridTableSwitcher from '@/components/GridTableSwitcher'; -import { Response } from '@/lib/api/response'; import { readToDataURL } from '@/lib/base64'; -import { User } from '@/lib/db/models/user'; +import { LimitedUser } from '@/lib/db/models/user'; import { fetchApi } from '@/lib/fetchApi'; import { canInteract } from '@/lib/role'; import { useUserStore } from '@/lib/client/store/user'; @@ -68,7 +67,7 @@ export default function DashboardUsers() { } } - const { data, error } = await fetchApi>('/api/users', 'POST', { + const { data, error } = await fetchApi('/api/users', 'POST', { username: values.username, password: values.password, role: values.role ?? 'USER', diff --git a/src/components/pages/users/views/UserGridView.tsx b/src/components/pages/users/views/UserGridView.tsx index 49b1c022..14d6c60d 100644 --- a/src/components/pages/users/views/UserGridView.tsx +++ b/src/components/pages/users/views/UserGridView.tsx @@ -1,13 +1,11 @@ -import { Response } from '@/lib/api/response'; -import { User } from '@/lib/db/models/user'; +import { LimitedUser } from '@/lib/db/models/user'; import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core'; import { IconFilesOff } from '@tabler/icons-react'; import useSWR from 'swr'; import UserCard from '../UserCard'; export default function UserGridView() { - const { data: users, isLoading } = - useSWR>('/api/users?noincl=true'); + const { data: users, isLoading } = useSWR('/api/users?noincl=true'); return ( <> diff --git a/src/components/pages/users/views/UserTableView.tsx b/src/components/pages/users/views/UserTableView.tsx index 86d3557c..c2f37151 100644 --- a/src/components/pages/users/views/UserTableView.tsx +++ b/src/components/pages/users/views/UserTableView.tsx @@ -1,7 +1,6 @@ import RelativeDate from '@/components/RelativeDate'; -import { Response } from '@/lib/api/response'; import { useUserStore } from '@/lib/client/store/user'; -import { User } from '@/lib/db/models/user'; +import { LimitedUser } from '@/lib/db/models/user'; import { canInteract, roleName } from '@/lib/role'; import { ActionIcon, Avatar, Box, Group, Tooltip } from '@mantine/core'; import { IconEdit, IconFiles, IconTrashFilled } from '@tabler/icons-react'; @@ -15,20 +14,20 @@ import EditUserModal from '../EditUserModal'; export default function UserTableView() { const currentUser = useUserStore((state) => state.user); - const { data, isLoading } = useSWR>('/api/users?noincl=true'); + const { data, isLoading } = useSWR('/api/users?noincl=true'); - const [selectedUser, setSelectedUser] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); const [sortStatus, setSortStatus] = useState({ columnAccessor: 'createdAt', direction: 'desc', }); - const sorted = useMemo(() => { + const sorted = useMemo(() => { if (!data) return []; const { columnAccessor, direction } = sortStatus; - const key = columnAccessor as keyof User; + const key = columnAccessor as keyof LimitedUser; return [...data].sort((a, b) => { const av = a[key]!; diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index fd53fe97..3080886f 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -92,6 +92,7 @@ export const API_ERRORS = { 3016: 'OAuth registration is disabled', 3017: 'OAuth login is not allowed for this account', 3018: 'Invalid access token provided.', + 3019: 'You cannot modify this user', // 4xxx, not founds 4000: 'File not found', diff --git a/src/lib/db/models/user.ts b/src/lib/db/models/user.ts index 154db34b..b552cc2e 100644 --- a/src/lib/db/models/user.ts +++ b/src/lib/db/models/user.ts @@ -14,6 +14,16 @@ export const userSelect = { sessions: true, }; +export const limitedUserSelect = { + id: true, + username: true, + createdAt: true, + updatedAt: true, + role: true, + view: true, + quota: true, +}; + export const userViewSchema = z .object({ enabled: z.boolean().nullish(), @@ -106,3 +116,14 @@ export const userSchema = z.object({ }); export type User = z.infer; + +export const limitedUserSchema = userSchema.omit({ + oauthProviders: true, + totpSecret: true, + passkeys: true, + sessions: true, + password: true, + token: true, +}); + +export type LimitedUser = z.infer; diff --git a/src/lib/role.ts b/src/lib/role.ts index d4f33092..80c95397 100644 --- a/src/lib/role.ts +++ b/src/lib/role.ts @@ -11,6 +11,12 @@ export function canInteract(current?: Role, target?: Role) { ); } +export function interactableRoles(current?: Role): Role[] { + if (current === 'SUPERADMIN') return ['USER', 'ADMIN']; + if (current === 'ADMIN') return ['USER']; + return []; +} + export function roleName(role?: Role) { switch (role) { case 'USER': diff --git a/src/server/routes/api/server/export.ts b/src/server/routes/api/server/export.ts index d6bda00e..91cf3ab4 100644 --- a/src/server/routes/api/server/export.ts +++ b/src/server/routes/api/server/export.ts @@ -70,6 +70,8 @@ export default typedPlugin( preHandler: [userMiddleware, administratorMiddleware], }, async (req, res) => { + if (req.user.role !== 'SUPERADMIN') throw new ApiError(3015); + if (req.query.counts) { const counts = await getCounts(); diff --git a/src/server/routes/api/user/folders/index.ts b/src/server/routes/api/user/folders/index.ts index 2813f6b0..405a18c1 100644 --- a/src/server/routes/api/user/folders/index.ts +++ b/src/server/routes/api/user/folders/index.ts @@ -37,24 +37,23 @@ export default typedPlugin( preHandler: [userMiddleware], }, async (req, res) => { - const { noincl, user, parentId, root } = req.query; + const { noincl, user: userId, parentId, root } = req.query; - if (user) { - const user = await prisma.user.findUnique({ + if (userId) { + const targetUser = await prisma.user.findUnique({ where: { - id: req.user.id, + id: userId, }, }); - if (!user) throw new ApiError(4009); - if (req.user.id !== user.id) { - if (!canInteract(req.user.role, user.role)) throw new ApiError(4009); - } + if (!targetUser) throw new ApiError(4009); + if (req.user.id !== targetUser.id && !canInteract(req.user.role, targetUser.role)) + throw new ApiError(4009); } const folders = await prisma.folder.findMany({ where: { - userId: user || req.user.id, + userId: userId || req.user.id, ...(root && { parentId: null }), ...(parentId && { parentId }), }, diff --git a/src/server/routes/api/users/[id]/index.ts b/src/server/routes/api/users/[id]/index.ts index d5e060b5..390ad5ea 100644 --- a/src/server/routes/api/users/[id]/index.ts +++ b/src/server/routes/api/users/[id]/index.ts @@ -3,7 +3,7 @@ import { bytes } from '@/lib/bytes'; import { hashPassword } from '@/lib/crypto'; import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; -import { User, userSchema, userSelect } from '@/lib/db/models/user'; +import { LimitedUser, limitedUserSchema, limitedUserSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { canInteract } from '@/lib/role'; import { zStringTrimmed } from '@/lib/validation'; @@ -13,7 +13,7 @@ import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import { z } from 'zod'; -export type ApiUsersIdResponse = User; +export type ApiUsersIdResponse = LimitedUser; const logger = log('api').c('users').c('[id]'); @@ -31,7 +31,7 @@ export default typedPlugin( description: 'Fetch a specific user by ID, including their profile and role (admin only).', params: paramsSchema, response: { - 200: userSchema, + 200: limitedUserSchema, }, tags: ['auth', 'admin'], }, @@ -42,10 +42,11 @@ export default typedPlugin( where: { id: req.params.id, }, - select: userSelect, + select: limitedUserSelect, }); if (!user) throw new ApiError(4009); + if (!canInteract(req.user.role, user.role)) throw new ApiError(4009); return res.send(user); }, @@ -73,7 +74,7 @@ export default typedPlugin( .optional(), }), response: { - 200: userSchema, + 200: limitedUserSchema, }, tags: ['auth', 'admin'], }, @@ -84,9 +85,13 @@ export default typedPlugin( where: { id: req.params.id, }, - select: userSelect, + select: { + id: true, + role: true, + }, }); if (!user) throw new ApiError(4009); + if (!canInteract(req.user.role, user.role)) throw new ApiError(3019); const { username, password, avatar, role, quota } = req.body; if (role && !canInteract(req.user.role, role)) throw new ApiError(3007); @@ -149,11 +154,7 @@ export default typedPlugin( }, }), }, - select: { - ...userSelect, - totpSecret: false, - passkeys: false, - }, + select: limitedUserSelect, }); logger.info(`${req.user.username} updated another user`, { @@ -176,7 +177,7 @@ export default typedPlugin( delete: z.boolean().optional(), }), response: { - 200: userSchema, + 200: limitedUserSchema, }, tags: ['auth', 'admin'], }, @@ -187,7 +188,11 @@ export default typedPlugin( where: { id: req.params.id, }, - select: userSelect, + select: { + id: true, + role: true, + username: true, + }, }); if (!user) throw new ApiError(4009); @@ -242,10 +247,7 @@ export default typedPlugin( where: { id: user.id, }, - select: { - ...userSelect, - totpSecret: false, - }, + select: limitedUserSelect, }); logger.info(`${req.user.username} deleted another user`, { diff --git a/src/server/routes/api/users/index.ts b/src/server/routes/api/users/index.ts index c1856372..b28dc6be 100644 --- a/src/server/routes/api/users/index.ts +++ b/src/server/routes/api/users/index.ts @@ -2,10 +2,10 @@ import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; import { createToken, hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; -import { User, userSchema, userSelect } from '@/lib/db/models/user'; +import { LimitedUser, limitedUserSchema, limitedUserSelect } from '@/lib/db/models/user'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; -import { canInteract } from '@/lib/role'; +import { canInteract, interactableRoles } from '@/lib/role'; import { zQsBoolean, zStringTrimmed } from '@/lib/validation'; import { Role } from '@/prisma/client'; import { administratorMiddleware } from '@/server/middleware/administrator'; @@ -14,7 +14,7 @@ import typedPlugin from '@/server/typedPlugin'; import { readFile } from 'fs/promises'; import { z } from 'zod'; -export type ApiUsersResponse = User[] | User; +export type ApiUsersResponse = LimitedUser[] | LimitedUser; const logger = log('api').c('users'); @@ -33,19 +33,22 @@ export default typedPlugin( 'List users in the instance, optionally excluding the current admin from the results (admin only).', querystring: querySchema, response: { - 200: z.array(userSchema), + 200: z.array(limitedUserSchema), }, tags: ['auth', 'admin'], }, preHandler: [userMiddleware, administratorMiddleware], }, async (req, res) => { + const roles = interactableRoles(req.user.role); + const users = await prisma.user.findMany({ select: { - ...userSelect, + ...limitedUserSelect, avatar: true, }, where: { + role: { in: roles }, ...(req.query.noincl && { id: { not: req.user.id } }), }, }); @@ -67,7 +70,7 @@ export default typedPlugin( role: z.enum(Role).default('USER').optional(), }), response: { - 200: userSchema, + 200: limitedUserSchema, }, tags: ['auth', 'admin'], }, @@ -106,11 +109,7 @@ export default typedPlugin( avatar: avatar64 ?? null, token: createToken(), }, - select: { - ...userSelect, - totpSecret: false, - passkeys: false, - }, + select: limitedUserSelect, }); logger.info(`${req.user.username} created a new user`, {