Add max file size settings for user (#167)

This commit is contained in:
Hakan Shehu
2025-08-01 19:53:16 +02:00
committed by GitHub
parent db4aa16891
commit eaf2b4afb2
15 changed files with 186 additions and 102 deletions

View File

@@ -20,6 +20,7 @@ interface WorkspaceStorageAggregateRow {
interface UserStorageRow {
id: string;
storage_limit: string;
max_file_size: string;
storage_used: string | null;
}
@@ -79,6 +80,7 @@ export const workspaceStorageGetRoute: FastifyPluginCallbackZod = (
SELECT
u.id,
u.storage_limit,
u.max_file_size,
COALESCE(c.value, '0') as storage_used
FROM users u
LEFT JOIN counters c ON c.key = CONCAT(u.id, '.storage.used')
@@ -103,7 +105,7 @@ export const workspaceStorageGetRoute: FastifyPluginCallbackZod = (
})
.map(([subtype, size]) => ({
subtype: subtype as FileSubtype,
size: size.toString(),
storageUsed: size.toString(),
}));
const users = usersWithStorage.rows
@@ -119,13 +121,14 @@ export const workspaceStorageGetRoute: FastifyPluginCallbackZod = (
})
.map((user) => ({
id: user.id,
used: user.storage_used ?? '0',
limit: user.storage_limit,
storageUsed: user.storage_used ?? '0',
storageLimit: user.storage_limit,
maxFileSize: user.max_file_size,
}));
return {
limit: workspace.storage_limit,
used: totalUsed.toString(),
storageLimit: workspace.storage_limit,
storageUsed: totalUsed.toString(),
subtypes: subtypes,
users: users,
};

View File

@@ -55,13 +55,22 @@ export const userStorageUpdateRoute: FastifyPluginCallbackZod = (
});
}
const limit = BigInt(input.limit);
const storageLimit = BigInt(input.storageLimit);
const maxFileSize = BigInt(input.maxFileSize);
if (maxFileSize > storageLimit) {
return reply.code(400).send({
code: ApiErrorCode.ValidationError,
message: 'Max file size cannot be larger than storage limit.',
});
}
const updatedUser = await database
.updateTable('users')
.returningAll()
.set({
storage_limit: limit.toString(),
storage_limit: storageLimit.toString(),
max_file_size: maxFileSize.toString(),
updated_at: new Date(),
updated_by: user.account_id,
})

View File

@@ -19,7 +19,8 @@ export class UserStorageUpdateMutationHandler
try {
const body: UserStorageUpdateInput = {
limit: input.limit,
storageLimit: input.storageLimit,
maxFileSize: input.maxFileSize,
};
const output = await workspace.account.client

View File

@@ -38,8 +38,8 @@ export class UserStorageGetQueryHandler
return {
hasChanges: true,
result: {
limit: '0',
used: '0',
storageLimit: '0',
storageUsed: '0',
subtypes: [],
},
};
@@ -111,7 +111,7 @@ export class UserStorageGetQueryHandler
const subtypes: {
subtype: FileSubtype;
size: string;
storageUsed: string;
}[] = [];
let totalUsed = 0n;
@@ -120,14 +120,14 @@ export class UserStorageGetQueryHandler
const sizeString = row.total_size || '0';
subtypes.push({
subtype,
size: sizeString,
storageUsed: sizeString,
});
totalUsed += BigInt(sizeString);
}
return {
limit: workspace.storageLimit,
used: totalUsed.toString(),
storageLimit: workspace.storageLimit,
storageUsed: totalUsed.toString(),
subtypes,
};
}

View File

@@ -10,8 +10,8 @@ import { Event } from '@colanode/client/types/events';
import { WorkspaceStorageGetOutput } from '@colanode/core';
const EMPTY_STORAGE_OUTPUT: WorkspaceStorageGetOutput = {
limit: '0',
used: '0',
storageLimit: '0',
storageUsed: '0',
subtypes: [],
users: [],
};

View File

@@ -3,7 +3,8 @@ export type UserStorageUpdateMutationInput = {
accountId: string;
workspaceId: string;
userId: string;
limit: string;
storageLimit: string;
maxFileSize: string;
};
export type UserStorageUpdateMutationOutput = {

View File

@@ -7,11 +7,11 @@ export type UserStorageGetQueryInput = {
};
export type UserStorageGetQueryOutput = {
limit: string;
used: string;
storageLimit: string;
storageUsed: string;
subtypes: {
subtype: FileSubtype;
size: string;
storageUsed: string;
}[];
};

View File

@@ -4,7 +4,7 @@ import { fileSubtypeSchema } from '@colanode/core/types/files';
export const workspaceStorageFileSubtypeSchema = z.object({
subtype: fileSubtypeSchema,
size: z.string(),
storageUsed: z.string(),
});
export type WorkspaceStorageFileSubtype = z.infer<
@@ -13,15 +13,16 @@ export type WorkspaceStorageFileSubtype = z.infer<
export const workspaceStorageUserSchema = z.object({
id: z.string(),
used: z.string(),
limit: z.string(),
storageUsed: z.string(),
storageLimit: z.string(),
maxFileSize: z.string(),
});
export type WorkspaceStorageUser = z.infer<typeof workspaceStorageUserSchema>;
export const workspaceStorageGetOutputSchema = z.object({
limit: z.string().nullable().optional(),
used: z.string(),
storageLimit: z.string().nullable().optional(),
storageUsed: z.string(),
subtypes: z.array(workspaceStorageFileSubtypeSchema),
users: z.array(workspaceStorageUserSchema),
});

View File

@@ -106,7 +106,8 @@ export const userRoleUpdateInputSchema = z.object({
export type UserRoleUpdateInput = z.infer<typeof userRoleUpdateInputSchema>;
export const userStorageUpdateInputSchema = z.object({
limit: z.string(),
storageLimit: z.string(),
maxFileSize: z.string(),
});
export type UserStorageUpdateInput = z.infer<

View File

@@ -37,31 +37,33 @@ const subtypeMetadata: SubtypeMetadata[] = [
interface StorageSubtype {
subtype: string;
size: string;
storageUsed: string;
}
interface StorageStatsProps {
usedBytes: bigint;
limitBytes: bigint | null;
storageUsed: string;
storageLimit: string | null;
subtypes: StorageSubtype[];
}
export const StorageStats = ({
usedBytes,
limitBytes,
storageUsed,
storageLimit,
subtypes,
}: StorageStatsProps) => {
const usedPercentage = limitBytes
? bigintToPercent(limitBytes, usedBytes)
const usedPercentage = storageLimit
? bigintToPercent(BigInt(storageLimit), BigInt(storageUsed))
: 0;
return (
<div className="space-y-2">
<div className="flex gap-2 items-baseline">
<span className="text-2xl font-medium">{formatBytes(usedBytes)}</span>
<span className="text-2xl font-medium">
{formatBytes(BigInt(storageUsed))}
</span>
<span className="text-xl text-muted-foreground">
{' '}
of {limitBytes ? formatBytes(limitBytes) : 'Unlimited'}
of {storageLimit ? formatBytes(BigInt(storageLimit)) : 'Unlimited'}
</span>
<span className="text-sm text-muted-foreground">
({usedPercentage}%) used
@@ -69,12 +71,14 @@ export const StorageStats = ({
</div>
<div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden flex">
{subtypes.map((subtype) => {
const size = BigInt(subtype.size);
if (size === BigInt(0)) {
const storageUsed = BigInt(subtype.storageUsed);
if (storageUsed === BigInt(0)) {
return null;
}
const percentage = limitBytes ? bigintToPercent(limitBytes, size) : 0;
const percentage = storageLimit
? bigintToPercent(BigInt(storageLimit), storageUsed)
: 0;
const metadata = subtypeMetadata.find(
(m) => m.subtype === subtype.subtype
);
@@ -85,7 +89,7 @@ export const StorageStats = ({
const name = metadata.name;
const color = metadata.color;
const title = `${name}: ${formatBytes(BigInt(subtype.size))}`;
const title = `${name}: ${formatBytes(BigInt(subtype.storageUsed))}`;
return (
<div
@@ -106,8 +110,8 @@ export const StorageStats = ({
<div className="mb-6 space-y-2">
{subtypes.map((subtype) => {
const size = BigInt(subtype.size);
if (size === BigInt(0)) {
const storageUsed = BigInt(subtype.storageUsed);
if (storageUsed === BigInt(0)) {
return null;
}
@@ -132,7 +136,7 @@ export const StorageStats = ({
<span className="text-sm">{name}</span>
</div>
<span className="text-sm text-muted-foreground">
{formatBytes(BigInt(subtype.size))}
{formatBytes(BigInt(subtype.storageUsed))}
</span>
</div>
);

View File

@@ -13,12 +13,10 @@ export const UserStorageStats = () => {
});
const data = userStorageGetQuery.data ?? {
limit: '0',
used: '0',
storageLimit: '0',
storageUsed: '0',
subtypes: [],
};
const usedBytes = BigInt(data.used);
const limitBytes = BigInt(data.limit);
return (
<div className="space-y-6">
@@ -32,8 +30,8 @@ export const UserStorageStats = () => {
</div>
) : (
<StorageStats
usedBytes={usedBytes}
limitBytes={limitBytes}
storageUsed={data.storageUsed}
storageLimit={data.storageLimit}
subtypes={data.subtypes}
/>
)}

View File

@@ -26,13 +26,11 @@ export const WorkspaceStorageStats = () => {
);
const data = workspaceStorageGetQuery.data ?? {
limit: '0',
used: '0',
storageLimit: '0',
storageUsed: '0',
subtypes: [],
users: [],
};
const usedBytes = BigInt(data.used);
const limitBytes = data.limit ? BigInt(data.limit) : null;
return (
<div className="space-y-10">
@@ -61,8 +59,8 @@ export const WorkspaceStorageStats = () => {
.with({ isPending: false, isError: false }, () => (
<>
<StorageStats
usedBytes={usedBytes}
limitBytes={limitBytes}
storageUsed={data.storageUsed}
storageLimit={data.storageLimit ?? null}
subtypes={data.subtypes}
/>
<WorkspaceStorageUserTable

View File

@@ -1,7 +1,7 @@
import { Settings } from 'lucide-react';
import { useState } from 'react';
import { formatBytes } from '@colanode/core';
import { formatBytes, WorkspaceStorageUser } from '@colanode/core';
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { Button } from '@colanode/ui/components/ui/button';
import {
@@ -17,22 +17,11 @@ import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
import { bigintToPercent, cn } from '@colanode/ui/lib/utils';
interface WorkspaceStorageUser {
id: string;
used: string;
limit: string;
}
interface UserStorageProgressBarProps {
used: bigint;
limit: bigint;
}
const UserStorageProgressBar = ({
used,
limit,
}: UserStorageProgressBarProps) => {
const percentage = limit > 0n ? bigintToPercent(limit, used) : 0;
storageUsed,
storageLimit,
}: WorkspaceStorageUser) => {
const percentage = bigintToPercent(BigInt(storageLimit), BigInt(storageUsed));
const getBarColor = () => {
if (percentage >= 90) return 'bg-red-500';
@@ -43,7 +32,7 @@ const UserStorageProgressBar = ({
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{formatBytes(used)}</span>
<span className="font-medium">{formatBytes(BigInt(storageUsed))}</span>
<span className="text-muted-foreground">
({percentage.toFixed(1)}%)
</span>
@@ -81,8 +70,8 @@ const WorkspaceStorageUserRow = ({
const email = userQuery.data?.email ?? '';
const avatar = userQuery.data?.avatar ?? null;
const usedBytes = BigInt(user.used);
const limitBytes = BigInt(user.limit);
const storageLimitBytes = BigInt(user.storageLimit);
const maxFileSizeBytes = user.maxFileSize ? BigInt(user.maxFileSize) : null;
return (
<>
@@ -99,10 +88,17 @@ const WorkspaceStorageUserRow = ({
</div>
</TableCell>
<TableCell className="text-center">
<span className="text-sm font-medium">{formatBytes(limitBytes)}</span>
<span className="text-sm font-medium">
{maxFileSizeBytes ? formatBytes(maxFileSizeBytes) : '#'}
</span>
</TableCell>
<TableCell className="text-center">
<span className="text-sm font-medium">
{formatBytes(storageLimitBytes)}
</span>
</TableCell>
<TableCell className="min-w-[200px] text-center">
<UserStorageProgressBar used={usedBytes} limit={limitBytes} />
<UserStorageProgressBar {...user} />
</TableCell>
<TableCell className="w-10 text-right">
<Button
@@ -117,8 +113,7 @@ const WorkspaceStorageUserRow = ({
</TableRow>
{openUpdateDialog && (
<WorkspaceStorageUserUpdateDialog
userId={user.id}
limit={user.limit}
user={user}
open={openUpdateDialog}
onOpenChange={setOpenUpdateDialog}
onUpdate={() => {
@@ -149,6 +144,7 @@ export const WorkspaceStorageUserTable = ({
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead className="text-center">File Size Limit</TableHead>
<TableHead className="text-center">Total Storage</TableHead>
<TableHead className="text-center">Used Storage</TableHead>
<TableHead className="w-10 text-right"></TableHead>

View File

@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod/v4';
import { WorkspaceStorageUser } from '@colanode/core';
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { Button } from '@colanode/ui/components/ui/button';
import {
@@ -75,20 +76,19 @@ const formatBytes = (bytes: string): string => {
};
const formSchema = z.object({
limit: z.string().min(1, 'Storage limit is required'),
storageLimit: z.string().min(1, 'Storage limit is required'),
maxFileSize: z.string().min(1, 'Max file size is required'),
});
interface WorkspaceStorageUserUpdateDialogProps {
userId: string;
limit: string;
user: WorkspaceStorageUser;
open: boolean;
onOpenChange: (open: boolean) => void;
onUpdate: () => void;
}
export const WorkspaceStorageUserUpdateDialog = ({
userId,
limit,
user,
open,
onOpenChange,
onUpdate,
@@ -96,15 +96,21 @@ export const WorkspaceStorageUserUpdateDialog = ({
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
const initialLimit = convertBytesToUnit(limit);
const initialStorageLimit = convertBytesToUnit(user.storageLimit);
const initialMaxFileSize = convertBytesToUnit(user.maxFileSize);
const [limitUnit, setLimitUnit] = useState(initialLimit.unit);
const [storageLimitUnit, setStorageLimitUnit] = useState(
initialStorageLimit.unit
);
const [maxFileSizeUnit, setMaxFileSizeUnit] = useState(
initialMaxFileSize.unit
);
const userQuery = useQuery({
type: 'user.get',
accountId: workspace.accountId,
workspaceId: workspace.id,
userId,
userId: user.id,
});
const name = userQuery.data?.name ?? 'Unknown';
@@ -114,13 +120,15 @@ export const WorkspaceStorageUserUpdateDialog = ({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
limit: initialLimit.value,
storageLimit: initialStorageLimit.value,
maxFileSize: initialMaxFileSize.value,
},
});
const handleCancel = () => {
form.reset();
setLimitUnit(initialLimit.unit);
setStorageLimitUnit(initialStorageLimit.unit);
setMaxFileSizeUnit(initialMaxFileSize.unit);
onOpenChange(false);
};
@@ -130,16 +138,23 @@ export const WorkspaceStorageUserUpdateDialog = ({
}
const apiValues = {
limit: convertUnitToBytes(values.limit, limitUnit),
storageLimit: convertUnitToBytes(values.storageLimit, storageLimitUnit),
maxFileSize: convertUnitToBytes(values.maxFileSize, maxFileSizeUnit),
};
if (BigInt(apiValues.maxFileSize) > BigInt(apiValues.storageLimit)) {
toast.error('Max file size cannot be larger than storage limit');
return;
}
mutate({
input: {
type: 'user.storage.update',
accountId: workspace.accountId,
workspaceId: workspace.id,
userId,
limit: apiValues.limit,
userId: user.id,
storageLimit: apiValues.storageLimit,
maxFileSize: apiValues.maxFileSize,
},
onSuccess: () => {
toast.success('User storage settings updated');
@@ -152,8 +167,10 @@ export const WorkspaceStorageUserUpdateDialog = ({
});
};
const unit = UNITS.find((u) => u.value === limitUnit);
const unitLabel = unit?.label ?? 'bytes';
const storageLimitUnitData = UNITS.find((u) => u.value === storageLimitUnit);
const storageLimitUnitLabel = storageLimitUnitData?.label ?? 'bytes';
const maxFileSizeUnitData = UNITS.find((u) => u.value === maxFileSizeUnit);
const maxFileSizeUnitLabel = maxFileSizeUnitData?.label ?? 'bytes';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -161,11 +178,11 @@ export const WorkspaceStorageUserUpdateDialog = ({
<DialogHeader>
<DialogTitle>Update storage settings</DialogTitle>
<DialogDescription>
Update the storage limit for this user
Update the storage limits for this user
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-3 py-4 border-b">
<Avatar id={userId} name={name} avatar={avatar} />
<Avatar id={user.id} name={name} avatar={avatar} />
<div className="flex-grow min-w-0">
<p className="text-sm font-medium leading-none truncate">{name}</p>
<p className="text-sm text-muted-foreground truncate">{email}</p>
@@ -179,7 +196,7 @@ export const WorkspaceStorageUserUpdateDialog = ({
<div className="flex-grow space-y-6 py-2 pb-4">
<FormField
control={form.control}
name="limit"
name="storageLimit"
render={({ field }) => (
<FormItem>
<FormLabel>Storage Limit</FormLabel>
@@ -190,8 +207,8 @@ export const WorkspaceStorageUserUpdateDialog = ({
placeholder="5"
{...field}
className="flex-1"
min="0"
step="0.01"
min="1"
step="1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -199,7 +216,7 @@ export const WorkspaceStorageUserUpdateDialog = ({
variant="outline"
className="w-20 justify-between"
>
{unitLabel}
{storageLimitUnitLabel}
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -207,11 +224,11 @@ export const WorkspaceStorageUserUpdateDialog = ({
{UNITS.map((unit) => (
<DropdownMenuItem
key={unit.value}
onClick={() => setLimitUnit(unit.value)}
onClick={() => setStorageLimitUnit(unit.value)}
className="flex items-center justify-between"
>
<span>{unit.label}</span>
{limitUnit === unit.value && (
{storageLimitUnit === unit.value && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
@@ -223,7 +240,62 @@ export const WorkspaceStorageUserUpdateDialog = ({
<div className="text-xs text-muted-foreground">
={' '}
{formatBytes(
convertUnitToBytes(field.value || '0', limitUnit)
convertUnitToBytes(field.value || '0', storageLimitUnit)
)}{' '}
bytes
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxFileSize"
render={({ field }) => (
<FormItem>
<FormLabel>Max File Size</FormLabel>
<FormControl>
<div className="flex space-x-2">
<Input
type="number"
placeholder="10"
{...field}
value={field.value || ''}
className="flex-1"
min="1"
step="1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-20 justify-between"
>
{maxFileSizeUnitLabel}
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{UNITS.map((unit) => (
<DropdownMenuItem
key={unit.value}
onClick={() => setMaxFileSizeUnit(unit.value)}
className="flex items-center justify-between"
>
<span>{unit.label}</span>
{maxFileSizeUnit === unit.value && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</FormControl>
<div className="text-xs text-muted-foreground">
={' '}
{formatBytes(
convertUnitToBytes(field.value || '0', maxFileSizeUnit)
)}{' '}
bytes
</div>

View File

@@ -1,7 +1,7 @@
import semver from 'semver';
export const FeatureVersions = {
'workspace.storage.management': '0.2.9',
'workspace.storage.management': '0.3.0',
} as const;
export type FeatureKey = keyof typeof FeatureVersions;