Add global command channel rules and enhance command handling

- Introduced global command channel whitelist and blacklist in the GuildSettings model for improved command access control.
- Implemented command gate logic in the command routing to handle channel and role restrictions, providing user feedback for denied commands.
- Enhanced the CommandsManager component to support global rules and channel/role selection, improving the user interface for managing command permissions.
- Updated localization files to include new keys for global command rules and related messages, ensuring clarity in user interactions.
This commit is contained in:
TheOnlyMace
2026-07-22 20:26:40 +02:00
parent 0fb8195236
commit d31660f813
24 changed files with 963 additions and 185 deletions

View File

@@ -1,6 +1,11 @@
'use client';
import type { CommandOverrideDashboard } from '@nexumi/shared';
import type {
CommandGlobalRules,
CommandOverrideDashboard,
DiscordChannelOption,
DiscordRoleOption
} from '@nexumi/shared';
import { RotateCcw } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
@@ -8,20 +13,10 @@ import { toast } from 'sonner';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
interface LocalOverride extends CommandOverrideDashboard {
saving?: boolean;
}
@@ -29,13 +24,34 @@ interface LocalOverride extends CommandOverrideDashboard {
interface CommandsManagerProps {
guildId: string;
initialCommands: CommandOverrideDashboard[];
initialGlobalRules: CommandGlobalRules;
channels: DiscordChannelOption[];
roles: DiscordRoleOption[];
}
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
export function CommandsManager({
guildId,
initialCommands,
initialGlobalRules,
channels,
roles
}: CommandsManagerProps) {
const t = useTranslations();
const searchParams = useSearchParams();
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
const [search, setSearch] = useState('');
const [globalRules, setGlobalRules] = useState(initialGlobalRules);
const [globalSaving, setGlobalSaving] = useState(false);
const [globalDirty, setGlobalDirty] = useState(false);
const channelOptions = useMemo(
() => channels.map((channel) => ({ id: channel.id, name: channel.name, prefix: '#' })),
[channels]
);
const roleOptions = useMemo(
() => roles.map((role) => ({ id: role.id, name: role.name, prefix: '@' })),
[roles]
);
useEffect(() => {
const q = searchParams.get('q');
@@ -58,6 +74,37 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
);
}
function patchGlobal(patch: Partial<CommandGlobalRules>) {
setGlobalRules((prev) => ({ ...prev, ...patch }));
setGlobalDirty(true);
}
async function handleSaveGlobal() {
setGlobalSaving(true);
try {
const response = await fetch(`/api/guilds/${guildId}/commands/globals`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(globalRules)
});
const body = (await response.json().catch(() => null)) as (CommandGlobalRules & { error?: string }) | null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
return;
}
setGlobalRules({
allowedChannelIds: body.allowedChannelIds,
deniedChannelIds: body.deniedChannelIds
});
setGlobalDirty(false);
toast.success(t('common.saveSuccess'));
} catch {
toast.error(t('common.saveError'));
} finally {
setGlobalSaving(false);
}
}
async function handleSave(entry: LocalOverride) {
patchLocal(entry.commandName, { saving: true });
try {
@@ -110,122 +157,168 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
}
return (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.commands.searchPlaceholder')}
className="max-w-sm"
/>
<div className="space-y-3">
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
)}
{filtered.map((entry) => (
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
<div className="flex items-center gap-2">
<Switch
checked={entry.enabled}
disabled={entry.saving}
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
/>
<span className="text-xs text-muted-foreground">
{entry.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
<Input
type="number"
min={0}
value={entry.cooldownSeconds ?? ''}
onChange={(event) =>
patchLocal(entry.commandName, {
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
})
}
placeholder={t('common.optional')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
<Input
value={toCsv(entry.allowedRoleIds)}
onChange={(event) =>
patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
<Input
value={toCsv(entry.deniedRoleIds)}
onChange={(event) =>
patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
<Input
value={toCsv(entry.allowedChannelIds)}
onChange={(event) =>
patchLocal(entry.commandName, {
allowedChannelIds: fromCsv(event.target.value)
})
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
<Input
value={toCsv(entry.deniedChannelIds)}
onChange={(event) =>
patchLocal(entry.commandName, {
deniedChannelIds: fromCsv(event.target.value)
})
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={entry.saving}
onClick={() => handleReset(entry)}
>
<RotateCcw className="size-4" />
{t('modulePages.commands.reset')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={entry.saving}
onClick={() => handleSave(entry)}
>
{entry.saving ? t('common.saving') : t('common.save')}
</Button>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.globalTitle')}</CardTitle>
<CardDescription>{t('modulePages.commands.globalDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div id="field-commands-global-allow" className="scroll-mt-24 space-y-2">
<p className="text-sm font-medium">{t('modulePages.commands.globalAllowedChannels')}</p>
<p className="text-xs text-muted-foreground">{t('modulePages.commands.globalAllowedHint')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={globalRules.allowedChannelIds}
onChange={(allowedChannelIds) => patchGlobal({ allowedChannelIds })}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={globalSaving}
/>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-2">
<div id="field-commands-global-deny" className="scroll-mt-24 space-y-2">
<p className="text-sm font-medium">{t('modulePages.commands.globalDeniedChannels')}</p>
<p className="text-xs text-muted-foreground">{t('modulePages.commands.globalDeniedHint')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={globalRules.deniedChannelIds}
onChange={(deniedChannelIds) => patchGlobal({ deniedChannelIds })}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={globalSaving}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
disabled={!globalDirty || globalSaving}
onClick={() => void handleSaveGlobal()}
>
{globalSaving ? t('common.saving') : t('common.save')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.commands.searchPlaceholder')}
className="max-w-sm"
/>
<div className="space-y-3">
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
)}
{filtered.map((entry) => (
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
<div className="flex items-center gap-2">
<Switch
checked={entry.enabled}
disabled={entry.saving}
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
/>
<span className="text-xs text-muted-foreground">
{entry.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
<Input
type="number"
min={0}
value={entry.cooldownSeconds ?? ''}
onChange={(event) =>
patchLocal(entry.commandName, {
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
})
}
placeholder={t('common.optional')}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
<DiscordResourceMultiSelect
options={roleOptions}
value={entry.allowedRoleIds}
onChange={(allowedRoleIds) => patchLocal(entry.commandName, { allowedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
<DiscordResourceMultiSelect
options={roleOptions}
value={entry.deniedRoleIds}
onChange={(deniedRoleIds) => patchLocal(entry.commandName, { deniedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={entry.allowedChannelIds}
onChange={(allowedChannelIds) =>
patchLocal(entry.commandName, { allowedChannelIds })
}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={entry.deniedChannelIds}
onChange={(deniedChannelIds) =>
patchLocal(entry.commandName, { deniedChannelIds })
}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={entry.saving}
onClick={() => void handleReset(entry)}
>
<RotateCcw className="size-4" />
{t('modulePages.commands.reset')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={entry.saving}
onClick={() => void handleSave(entry)}
>
{entry.saving ? t('common.saving') : t('common.save')}
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}