Implement premium features and GDPR compliance in bot and WebUI

- Added new models for premium tier configurations and assignments, enabling feature limits for free and premium users.
- Introduced GDPR deletion logging and commands for user data management.
- Enhanced logging configuration with retention days for audit logs.
- Updated bot commands and job processing to include new premium and GDPR functionalities.
- Improved localization with new keys for premium features and GDPR commands in both English and German.
- Added UI components for managing premium settings and logging retention in the WebUI.
This commit is contained in:
smueller
2026-07-22 16:59:23 +02:00
parent 4852f16f79
commit 08af99ed52
42 changed files with 1546 additions and 30 deletions

View File

@@ -52,6 +52,7 @@ interface LocalLoggingValue {
ignoreBots: boolean;
ignoredChannelIdsText: string;
ignoredRoleIdsText: string;
retentionDays: number;
logChannels: LocalLogChannel[];
}
@@ -72,6 +73,7 @@ function toLocal(config: LoggingConfigDashboard): LocalLoggingValue {
ignoreBots: config.ignoreBots,
ignoredChannelIdsText: toCsv(config.ignoredChannelIds),
ignoredRoleIdsText: toCsv(config.ignoredRoleIds),
retentionDays: config.retentionDays,
logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` }))
};
}
@@ -82,6 +84,7 @@ function toRemote(value: LocalLoggingValue): LoggingConfigDashboard {
ignoreBots: value.ignoreBots,
ignoredChannelIds: fromCsv(value.ignoredChannelIdsText),
ignoredRoleIds: fromCsv(value.ignoredRoleIdsText),
retentionDays: value.retentionDays,
logChannels: value.logChannels.map(({ clientKey: _clientKey, ...rest }) => rest)
};
}
@@ -160,6 +163,21 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="logging-retention">{t('modulePages.logging.retentionLabel')}</Label>
<Input
id="logging-retention"
type="number"
min={0}
max={3650}
className="w-40"
value={value.retentionDays}
onChange={(event) =>
setValue((prev) => ({ ...prev, retentionDays: Number(event.target.value) || 0 }))
}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.retentionHint')}</p>
</div>
</CardContent>
</Card>

View File

@@ -3,6 +3,7 @@
import {
Activity,
BookOpen,
Crown,
Flag,
LayoutDashboard,
ListTodo,
@@ -22,6 +23,7 @@ const LINKS = [
{ href: '/owner/guilds', icon: Server, key: 'guilds' },
{ href: '/owner/users', icon: Users, key: 'users' },
{ href: '/owner/flags', icon: Flag, key: 'flags' },
{ href: '/owner/premium', icon: Crown, key: 'premium' },
{ href: '/owner/presence', icon: Radio, key: 'presence' },
{ href: '/owner/team', icon: Users, key: 'team' },
{ href: '/owner/jobs', icon: ListTodo, key: 'jobs' },

View File

@@ -0,0 +1,305 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { toast } from 'sonner';
import type { PremiumTier, PremiumTierConfig } from '@nexumi/shared';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
async function readError(response: Response): Promise<string> {
const data = (await response.json().catch(() => null)) as { error?: string } | null;
return data?.error ?? 'Request failed';
}
type GuildAssignment = {
id: string;
guildId: string;
tier: string;
note: string | null;
expiresAt: string | null;
};
type UserAssignment = {
id: string;
userId: string;
tier: string;
note: string | null;
expiresAt: string | null;
};
export function PremiumManager({
initialTiers,
initialGuilds,
initialUsers
}: {
initialTiers: PremiumTierConfig[];
initialGuilds: GuildAssignment[];
initialUsers: UserAssignment[];
}) {
const t = useTranslations();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [tiers, setTiers] = useState(initialTiers);
const [guildId, setGuildId] = useState('');
const [guildTier, setGuildTier] = useState<PremiumTier>('PREMIUM');
const [userId, setUserId] = useState('');
const [userTier, setUserTier] = useState<PremiumTier>('PREMIUM');
function updateTierLocal(tier: PremiumTier, patch: Partial<PremiumTierConfig>) {
setTiers((prev) => prev.map((row) => (row.tier === tier ? { ...row, ...patch } : row)));
}
return (
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-2">
{tiers.map((tier) => (
<Card key={tier.tier}>
<CardHeader>
<CardTitle>{tier.tier}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label>{t('owner.premium.maxTags')}</Label>
<Input
type="number"
min={0}
value={tier.maxTags}
onChange={(e) => updateTierLocal(tier.tier, { maxTags: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label>{t('owner.premium.maxFeeds')}</Label>
<Input
type="number"
min={0}
value={tier.maxFeeds}
onChange={(e) => updateTierLocal(tier.tier, { maxFeeds: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label>{t('owner.premium.maxBackups')}</Label>
<Input
type="number"
min={0}
value={tier.maxBackups}
onChange={(e) => updateTierLocal(tier.tier, { maxBackups: Number(e.target.value) || 0 })}
/>
</div>
</div>
<div className="flex flex-wrap gap-4 text-sm">
{(['tags', 'feeds', 'guildbackup'] as const).map((feature) => (
<label key={feature} className="flex items-center gap-2">
<input
type="checkbox"
checked={tier.features[feature]}
onChange={(e) =>
updateTierLocal(tier.tier, {
features: { ...tier.features, [feature]: e.target.checked }
})
}
/>
{t(`owner.premium.feature.${feature}`)}
</label>
))}
</div>
<Button
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/premium', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier: tier.tier,
maxTags: tier.maxTags,
maxFeeds: tier.maxFeeds,
maxBackups: tier.maxBackups,
features: tier.features
})
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.save')}
</Button>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>{t('owner.premium.guildsTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Input
placeholder={t('owner.premium.guildIdPlaceholder')}
value={guildId}
onChange={(e) => setGuildId(e.target.value)}
/>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={guildTier}
onChange={(e) => setGuildTier(e.target.value as PremiumTier)}
>
<option value="FREE">FREE</option>
<option value="PREMIUM">PREMIUM</option>
</select>
<Button
disabled={pending || !guildId.trim()}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/premium', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'guild', guildId: guildId.trim(), tier: guildTier, note: null })
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
setGuildId('');
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.add')}
</Button>
</div>
<div className="divide-y divide-border rounded-md border border-border">
{initialGuilds.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
) : (
initialGuilds.map((row) => (
<div key={row.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">
{row.guildId} · {row.tier}
</p>
{row.expiresAt ? (
<p className="text-muted-foreground">
{t('owner.premium.expires')}: {new Date(row.expiresAt).toLocaleString()}
</p>
) : null}
</div>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch(`/api/owner/premium?guildId=${row.guildId}`, {
method: 'DELETE'
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.remove')}
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('owner.premium.usersTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Input
placeholder={t('owner.premium.userIdPlaceholder')}
value={userId}
onChange={(e) => setUserId(e.target.value)}
/>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={userTier}
onChange={(e) => setUserTier(e.target.value as PremiumTier)}
>
<option value="FREE">FREE</option>
<option value="PREMIUM">PREMIUM</option>
</select>
<Button
disabled={pending || !userId.trim()}
onClick={() =>
startTransition(async () => {
const response = await fetch('/api/owner/premium', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'user', userId: userId.trim(), tier: userTier, note: null })
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
setUserId('');
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.add')}
</Button>
</div>
<div className="divide-y divide-border rounded-md border border-border">
{initialUsers.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
) : (
initialUsers.map((row) => (
<div key={row.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">
{row.userId} · {row.tier}
</p>
</div>
<Button
size="sm"
variant="destructive"
disabled={pending}
onClick={() =>
startTransition(async () => {
const response = await fetch(`/api/owner/premium?userId=${row.userId}`, {
method: 'DELETE'
});
if (!response.ok) {
toast.error(await readError(response));
return;
}
toast.success(t('common.saveSuccess'));
router.refresh();
})
}
>
{t('common.remove')}
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -87,6 +87,18 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label htmlFor="general-snipe">{t('settings.general.snipeLabel')}</Label>
<p className="text-sm text-muted-foreground">{t('settings.general.snipeHint')}</p>
</div>
<Switch
id="general-snipe"
checked={value.snipeEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, snipeEnabled: checked }))}
/>
</div>
</CardContent>
</Card>
)}