- 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.
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
'use client';
|
|
|
|
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';
|
|
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';
|
|
|
|
interface LocalOverride extends CommandOverrideDashboard {
|
|
saving?: boolean;
|
|
}
|
|
|
|
interface CommandsManagerProps {
|
|
guildId: string;
|
|
initialCommands: CommandOverrideDashboard[];
|
|
initialGlobalRules: CommandGlobalRules;
|
|
channels: DiscordChannelOption[];
|
|
roles: DiscordRoleOption[];
|
|
}
|
|
|
|
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');
|
|
if (q) {
|
|
setSearch(q);
|
|
}
|
|
}, [searchParams]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const query = search.trim().toLowerCase();
|
|
if (!query) {
|
|
return commands;
|
|
}
|
|
return commands.filter((entry) => entry.commandName.toLowerCase().includes(query));
|
|
}, [commands, search]);
|
|
|
|
function patchLocal(commandName: string, patch: Partial<LocalOverride>) {
|
|
setCommands((prev) =>
|
|
prev.map((entry) => (entry.commandName === commandName ? { ...entry, ...patch } : entry))
|
|
);
|
|
}
|
|
|
|
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 {
|
|
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
enabled: entry.enabled,
|
|
allowedRoleIds: entry.allowedRoleIds,
|
|
deniedRoleIds: entry.deniedRoleIds,
|
|
allowedChannelIds: entry.allowedChannelIds,
|
|
deniedChannelIds: entry.deniedChannelIds,
|
|
cooldownSeconds: entry.cooldownSeconds
|
|
})
|
|
});
|
|
const body = (await response.json().catch(() => null)) as
|
|
| (CommandOverrideDashboard & { error?: string })
|
|
| null;
|
|
if (!response.ok || !body) {
|
|
toast.error(body?.error ?? t('common.saveError'));
|
|
return;
|
|
}
|
|
patchLocal(entry.commandName, body);
|
|
toast.success(t('common.saveSuccess'));
|
|
} catch {
|
|
toast.error(t('common.saveError'));
|
|
} finally {
|
|
patchLocal(entry.commandName, { saving: false });
|
|
}
|
|
}
|
|
|
|
async function handleReset(entry: LocalOverride) {
|
|
patchLocal(entry.commandName, { saving: true });
|
|
try {
|
|
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, {
|
|
method: 'DELETE'
|
|
});
|
|
const body = (await response.json().catch(() => null)) as CommandOverrideDashboard | null;
|
|
if (!response.ok || !body) {
|
|
toast.error(t('common.saveError'));
|
|
return;
|
|
}
|
|
patchLocal(entry.commandName, body);
|
|
toast.success(t('common.saveSuccess'));
|
|
} catch {
|
|
toast.error(t('common.saveError'));
|
|
} finally {
|
|
patchLocal(entry.commandName, { saving: false });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<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>
|
|
<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>
|
|
);
|
|
}
|