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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user