Merge commit from fork

This commit is contained in:
dicedtomato
2026-06-19 22:52:08 -07:00
committed by GitHub
parent 18bc86c261
commit 0fc7e7a06f
14 changed files with 89 additions and 58 deletions
+6 -1
View File
@@ -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 (
<>
<Group gap='sm'>
@@ -43,7 +48,7 @@ export default function DashboardServerActions() {
Useful tools and scripts for server management.
</Text>
<Stack gap='xs' my='sm'>
{ACTIONS.map(({ name, desc, Component }) => (
{actions.map(({ name, desc, Component }) => (
<Paper withBorder p='sm' key={name}>
<Group gap='md'>
<Component />
+2 -2
View File
@@ -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;
}) {
+2 -2
View File
@@ -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);
+3 -3
View File
@@ -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<Response['/api/users/[id]']>(`/api/users/${user.id}`, 'DELETE', {
delete: deleteFiles,
});
+2 -3
View File
@@ -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<Extract<Response['/api/users'], User>>('/api/users', 'POST', {
const { data, error } = await fetchApi<LimitedUser>('/api/users', 'POST', {
username: values.username,
password: values.password,
role: values.role ?? 'USER',
@@ -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<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
const { data: users, isLoading } = useSWR<LimitedUser[]>('/api/users?noincl=true');
return (
<>
@@ -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<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
const { data, isLoading } = useSWR<LimitedUser[]>('/api/users?noincl=true');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<LimitedUser | null>(null);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'desc',
});
const sorted = useMemo<User[]>(() => {
const sorted = useMemo<LimitedUser[]>(() => {
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]!;
+1
View File
@@ -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',
+21
View File
@@ -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<typeof userSchema>;
export const limitedUserSchema = userSchema.omit({
oauthProviders: true,
totpSecret: true,
passkeys: true,
sessions: true,
password: true,
token: true,
});
export type LimitedUser = z.infer<typeof limitedUserSchema>;
+6
View File
@@ -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':
+2
View File
@@ -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();
+8 -9
View File
@@ -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 }),
},
+19 -17
View File
@@ -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`, {
+10 -11
View File
@@ -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`, {