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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
305
apps/webui/src/components/owner/premium-manager.tsx
Normal file
305
apps/webui/src/components/owner/premium-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user