From 04e04eb11d0c9f9a175e1a76eff2c09f37a61c12 Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 13:56:33 +0200 Subject: [PATCH] Update fun module configuration and enhance CSS styles - Added 'enabled' property to FunConfigSchema with a default value of true for better configuration management. - Refactored getFunConfig to return a complete default configuration when no data is found. - Improved globals.css by expanding CSS variables for light and dark themes, enhancing styling consistency across the application. --- apps/bot/src/modules/fun/config.ts | 7 +- apps/webui/src/app/globals.css | 80 +++++- .../src/components/layout/dashboard-shell.tsx | 30 +++ .../src/components/layout/server-switcher.tsx | 77 ++++++ apps/webui/src/components/layout/sidebar.tsx | 86 ++++++ .../src/components/layout/theme-provider.tsx | 12 + .../webui/src/components/layout/user-menu.tsx | 73 +++++ apps/webui/src/components/locale-provider.tsx | 63 +++++ .../settings/general-settings-form.tsx | 95 +++++++ .../src/components/settings/save-bar.tsx | 43 +++ .../src/components/settings/settings-form.tsx | 73 +++++ apps/webui/src/components/ui/avatar.tsx | 43 +++ apps/webui/src/components/ui/badge.tsx | 29 ++ apps/webui/src/components/ui/button.tsx | 53 ++++ apps/webui/src/components/ui/card.tsx | 46 ++++ .../webui/src/components/ui/dropdown-menu.tsx | 175 ++++++++++++ apps/webui/src/components/ui/input.tsx | 22 ++ apps/webui/src/components/ui/label.tsx | 23 ++ apps/webui/src/components/ui/select.tsx | 148 ++++++++++ apps/webui/src/components/ui/separator.tsx | 26 ++ apps/webui/src/components/ui/skeleton.tsx | 14 + apps/webui/src/components/ui/switch.tsx | 29 ++ apps/webui/src/lib/access-rules.ts | 38 +++ apps/webui/src/lib/audit.ts | 55 ++++ apps/webui/src/lib/auth.ts | 99 +++++++ apps/webui/src/lib/discord-oauth.ts | 115 ++++++++ apps/webui/src/lib/env.ts | 19 ++ apps/webui/src/lib/guild-settings.ts | 41 +++ apps/webui/src/lib/guilds.ts | 51 ++++ apps/webui/src/lib/i18n.ts | 54 ++++ apps/webui/src/lib/modules.ts | 253 ++++++++++++++++++ apps/webui/src/lib/prisma.ts | 10 + apps/webui/src/lib/redis.ts | 12 + apps/webui/src/lib/session.ts | 105 ++++++++ apps/webui/src/lib/utils.test.ts | 16 ++ apps/webui/src/lib/utils.ts | 6 + apps/webui/src/messages/de.json | 143 ++++++++++ apps/webui/src/messages/en.json | 143 ++++++++++ apps/webui/tsconfig.json | 1 - 39 files changed, 2394 insertions(+), 14 deletions(-) create mode 100644 apps/webui/src/components/layout/dashboard-shell.tsx create mode 100644 apps/webui/src/components/layout/server-switcher.tsx create mode 100644 apps/webui/src/components/layout/sidebar.tsx create mode 100644 apps/webui/src/components/layout/theme-provider.tsx create mode 100644 apps/webui/src/components/layout/user-menu.tsx create mode 100644 apps/webui/src/components/locale-provider.tsx create mode 100644 apps/webui/src/components/settings/general-settings-form.tsx create mode 100644 apps/webui/src/components/settings/save-bar.tsx create mode 100644 apps/webui/src/components/settings/settings-form.tsx create mode 100644 apps/webui/src/components/ui/avatar.tsx create mode 100644 apps/webui/src/components/ui/badge.tsx create mode 100644 apps/webui/src/components/ui/button.tsx create mode 100644 apps/webui/src/components/ui/card.tsx create mode 100644 apps/webui/src/components/ui/dropdown-menu.tsx create mode 100644 apps/webui/src/components/ui/input.tsx create mode 100644 apps/webui/src/components/ui/label.tsx create mode 100644 apps/webui/src/components/ui/select.tsx create mode 100644 apps/webui/src/components/ui/separator.tsx create mode 100644 apps/webui/src/components/ui/skeleton.tsx create mode 100644 apps/webui/src/components/ui/switch.tsx create mode 100644 apps/webui/src/lib/access-rules.ts create mode 100644 apps/webui/src/lib/audit.ts create mode 100644 apps/webui/src/lib/auth.ts create mode 100644 apps/webui/src/lib/discord-oauth.ts create mode 100644 apps/webui/src/lib/env.ts create mode 100644 apps/webui/src/lib/guild-settings.ts create mode 100644 apps/webui/src/lib/guilds.ts create mode 100644 apps/webui/src/lib/i18n.ts create mode 100644 apps/webui/src/lib/modules.ts create mode 100644 apps/webui/src/lib/prisma.ts create mode 100644 apps/webui/src/lib/redis.ts create mode 100644 apps/webui/src/lib/session.ts create mode 100644 apps/webui/src/lib/utils.test.ts create mode 100644 apps/webui/src/lib/utils.ts create mode 100644 apps/webui/src/messages/de.json create mode 100644 apps/webui/src/messages/en.json diff --git a/apps/bot/src/modules/fun/config.ts b/apps/bot/src/modules/fun/config.ts index 0b1df6a..b81c642 100644 --- a/apps/bot/src/modules/fun/config.ts +++ b/apps/bot/src/modules/fun/config.ts @@ -4,18 +4,21 @@ import { z } from 'zod'; const FUN_CONFIG_PREFIX = 'fun:config:'; export const FunConfigSchema = z.object({ + enabled: z.boolean().default(true), mediaEnabled: z.boolean().default(true) }); export type FunConfig = z.infer; +const DEFAULT_FUN_CONFIG: FunConfig = { enabled: true, mediaEnabled: true }; + export async function getFunConfig(redis: Redis, guildId: string): Promise { const raw = await redis.get(`${FUN_CONFIG_PREFIX}${guildId}`); if (!raw) { - return { mediaEnabled: true }; + return { ...DEFAULT_FUN_CONFIG }; } const parsed = FunConfigSchema.safeParse(JSON.parse(raw)); - return parsed.success ? parsed.data : { mediaEnabled: true }; + return parsed.success ? parsed.data : { ...DEFAULT_FUN_CONFIG }; } export async function setFunConfig(redis: Redis, guildId: string, config: FunConfig): Promise { diff --git a/apps/webui/src/app/globals.css b/apps/webui/src/app/globals.css index a2dc41e..86addc2 100644 --- a/apps/webui/src/app/globals.css +++ b/apps/webui/src/app/globals.css @@ -1,26 +1,84 @@ -@import "tailwindcss"; +@import 'tailwindcss'; + +@custom-variant dark (&:is(.dark *)); :root { - --background: #ffffff; - --foreground: #171717; + --background: #f8f8fb; + --foreground: #111116; + --card: #ffffff; + --card-foreground: #111116; + --popover: #ffffff; + --popover-foreground: #111116; + --primary: #6366f1; + --primary-foreground: #ffffff; + --secondary: #f1f1f5; + --secondary-foreground: #111116; + --muted: #f1f1f5; + --muted-foreground: #6b7280; + --accent: #eef0ff; + --accent-foreground: #312e81; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #e4e4ea; + --input: #e4e4ea; + --ring: #6366f1; + --radius: 0.625rem; +} + +.dark { + --background: #0a0a0d; + --foreground: #e6e6ea; + --card: #131318; + --card-foreground: #e6e6ea; + --popover: #15151b; + --popover-foreground: #e6e6ea; + --primary: #6366f1; + --primary-foreground: #ffffff; + --secondary: #1b1b22; + --secondary-foreground: #e6e6ea; + --muted: #1b1b22; + --muted-foreground: #9a9aa5; + --accent: #1e1b4b; + --accent-foreground: #c7d2fe; + --destructive: #f87171; + --destructive-foreground: #1a0505; + --border: #23232b; + --input: #23232b; + --ring: #6366f1; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + --font-sans: var(--font-inter); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +* { + border-color: var(--border); } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; } diff --git a/apps/webui/src/components/layout/dashboard-shell.tsx b/apps/webui/src/components/layout/dashboard-shell.tsx new file mode 100644 index 0000000..e98fa0e --- /dev/null +++ b/apps/webui/src/components/layout/dashboard-shell.tsx @@ -0,0 +1,30 @@ +import type { DashboardGuild, SessionUser } from '@nexumi/shared'; +import type { ReactNode } from 'react'; +import { ServerSwitcher } from './server-switcher'; +import { Sidebar } from './sidebar'; +import { UserMenu } from './user-menu'; + +interface DashboardShellProps { + guildId: string; + currentGuild: DashboardGuild; + otherGuilds: DashboardGuild[]; + user: SessionUser; + children: ReactNode; +} + +export function DashboardShell({ guildId, currentGuild, otherGuilds, user, children }: DashboardShellProps) { + return ( +
+ +
+
+ + +
+
+
{children}
+
+
+
+ ); +} diff --git a/apps/webui/src/components/layout/server-switcher.tsx b/apps/webui/src/components/layout/server-switcher.tsx new file mode 100644 index 0000000..f0a3ce9 --- /dev/null +++ b/apps/webui/src/components/layout/server-switcher.tsx @@ -0,0 +1,77 @@ +'use client'; + +import type { DashboardGuild } from '@nexumi/shared'; +import { ChevronsUpDown, LayoutGrid } from 'lucide-react'; +import Link from 'next/link'; +import { useTranslations } from '@/components/locale-provider'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; + +function guildIconUrl(guild: Pick): string | undefined { + return guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` : undefined; +} + +function initials(name: string): string { + return name + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(''); +} + +interface ServerSwitcherProps { + currentGuild: DashboardGuild; + otherGuilds: DashboardGuild[]; +} + +export function ServerSwitcher({ currentGuild, otherGuilds }: ServerSwitcherProps) { + const t = useTranslations(); + const botGuilds = otherGuilds.filter((guild) => guild.botPresent); + + return ( + + + + + {initials(currentGuild.name)} + + {currentGuild.name} + + + + {t('serverSwitcher.switchServer')} + + {botGuilds.length === 0 ? ( +

{t('serverSwitcher.noOtherServers')}

+ ) : ( + botGuilds.map((guild) => ( + + + + + {initials(guild.name)} + + {guild.name} + + + )) + )} + + + + + {t('serverSwitcher.allServers')} + + +
+
+ ); +} diff --git a/apps/webui/src/components/layout/sidebar.tsx b/apps/webui/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..c306934 --- /dev/null +++ b/apps/webui/src/components/layout/sidebar.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared'; +import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useTranslations } from '@/components/locale-provider'; +import { cn } from '@/lib/utils'; + +const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [ + 'moderation', + 'engagement', + 'community', + 'utility', + 'integrations' +]; + +interface SidebarProps { + guildId: string; +} + +function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function Sidebar({ guildId }: SidebarProps) { + const pathname = usePathname(); + const t = useTranslations(); + const base = `/dashboard/${guildId}`; + + const groups = MODULE_GROUP_ORDER.map((group) => ({ + group, + modules: DASHBOARD_MODULES.filter((module) => module.group === group) + })); + + return ( + + ); +} diff --git a/apps/webui/src/components/layout/theme-provider.tsx b/apps/webui/src/components/layout/theme-provider.tsx new file mode 100644 index 0000000..41f7384 --- /dev/null +++ b/apps/webui/src/components/layout/theme-provider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import type { ReactNode } from 'react'; + +export function ThemeProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/webui/src/components/layout/user-menu.tsx b/apps/webui/src/components/layout/user-menu.tsx new file mode 100644 index 0000000..19071b2 --- /dev/null +++ b/apps/webui/src/components/layout/user-menu.tsx @@ -0,0 +1,73 @@ +'use client'; + +import type { SessionUser } from '@nexumi/shared'; +import { LogOut, Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTranslations } from '@/components/locale-provider'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; + +function userAvatarUrl(user: SessionUser): string | undefined { + return user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=64` : undefined; +} + +function initials(user: SessionUser): string { + const name = user.globalName ?? user.username; + return name.slice(0, 2).toUpperCase(); +} + +export function UserMenu({ user }: { user: SessionUser }) { + const t = useTranslations(); + const { theme, setTheme } = useTheme(); + const router = useRouter(); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + async function handleLogout() { + setIsLoggingOut(true); + try { + const response = await fetch('/api/auth/logout', { method: 'POST' }); + if (!response.ok) { + throw new Error('Logout failed'); + } + router.push('/login'); + router.refresh(); + } catch { + toast.error(t('errors.generic')); + setIsLoggingOut(false); + } + } + + return ( + + + + + {initials(user)} + + + + {user.globalName ?? user.username} + + setTheme(theme === 'dark' ? 'light' : 'dark')}> + {theme === 'dark' ? : } + {theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')} + + + + + {isLoggingOut ? t('userMenu.loggingOut') : t('userMenu.logout')} + + + + ); +} diff --git a/apps/webui/src/components/locale-provider.tsx b/apps/webui/src/components/locale-provider.tsx new file mode 100644 index 0000000..667c638 --- /dev/null +++ b/apps/webui/src/components/locale-provider.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import type { Locale, Messages } from '@/lib/i18n'; + +interface LocaleContextValue { + locale: Locale; + messages: Messages; +} + +const LocaleContext = createContext(null); + +export function LocaleProvider({ + locale, + messages, + children +}: { + locale: Locale; + messages: Messages; + children: ReactNode; +}) { + const value = useMemo(() => ({ locale, messages }), [locale, messages]); + return {children}; +} + +function useLocaleContext(): LocaleContextValue { + const ctx = useContext(LocaleContext); + if (!ctx) { + throw new Error('useTranslations/useLocale must be used within a LocaleProvider'); + } + return ctx; +} + +function resolve(messages: Messages, key: string): string | undefined { + const value = key.split('.').reduce((acc, part) => { + if (acc && typeof acc === 'object' && part in (acc as Record)) { + return (acc as Record)[part]; + } + return undefined; + }, messages as unknown); + return typeof value === 'string' ? value : undefined; +} + +function interpolate(template: string, vars?: Record): string { + if (!vars) { + return template; + } + return Object.entries(vars).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + template + ); +} + +export type TranslateFn = (key: string, vars?: Record) => string; + +export function useTranslations(): TranslateFn { + const { messages } = useLocaleContext(); + return (key, vars) => interpolate(resolve(messages, key) ?? key, vars); +} + +export function useLocale(): Locale { + return useLocaleContext().locale; +} diff --git a/apps/webui/src/components/settings/general-settings-form.tsx b/apps/webui/src/components/settings/general-settings-form.tsx new file mode 100644 index 0000000..9300fb3 --- /dev/null +++ b/apps/webui/src/components/settings/general-settings-form.tsx @@ -0,0 +1,95 @@ +'use client'; + +import type { GuildGeneralSettings } from '@nexumi/shared'; +import { useTranslations } from '@/components/locale-provider'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import type { SettingsSaveResult } from './settings-form'; +import { SettingsForm } from './settings-form'; + +interface GeneralSettingsFormProps { + guildId: string; + initialSettings: GuildGeneralSettings; +} + +async function saveGeneralSettings(guildId: string, value: GuildGeneralSettings): Promise { + try { + const response = await fetch(`/api/guilds/${guildId}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(value) + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` }; + } + return { ok: true }; + } catch { + return { ok: false, error: 'Network error' }; + } +} + +export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSettingsFormProps) { + const t = useTranslations(); + + return ( + + initialValue={initialSettings} + onSave={(value) => saveGeneralSettings(guildId, value)} + > + {({ value, setValue }) => ( + + + {t('settings.general.title')} + {t('settings.general.description')} + + +
+ + +

{t('settings.general.localeHint')}

+
+ +
+ + setValue((prev) => ({ ...prev, timezone: event.target.value }))} + placeholder="Europe/Berlin" + /> +

{t('settings.general.timezoneHint')}

+
+ +
+
+ +

{t('settings.general.moderationHint')}

+
+ setValue((prev) => ({ ...prev, moderationEnabled: checked }))} + /> +
+
+
+ )} + + ); +} diff --git a/apps/webui/src/components/settings/save-bar.tsx b/apps/webui/src/components/settings/save-bar.tsx new file mode 100644 index 0000000..1771180 --- /dev/null +++ b/apps/webui/src/components/settings/save-bar.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useTranslations } from '@/components/locale-provider'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface SaveBarProps { + visible: boolean; + saving: boolean; + onSave: () => void; + onDiscard: () => void; +} + +export function SaveBar({ visible, saving, onSave, onDiscard }: SaveBarProps) { + const t = useTranslations(); + + return ( +
+
+ {t('common.unsavedChanges')} +
+ + +
+
+
+ ); +} diff --git a/apps/webui/src/components/settings/settings-form.tsx b/apps/webui/src/components/settings/settings-form.tsx new file mode 100644 index 0000000..d07fd0d --- /dev/null +++ b/apps/webui/src/components/settings/settings-form.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useCallback, useState, useTransition, type ReactNode } from 'react'; +import { toast } from 'sonner'; +import { useTranslations } from '@/components/locale-provider'; +import { SaveBar } from './save-bar'; + +export type SettingsSaveResult = { ok: true } | { ok: false; error: string }; + +export interface SettingsFormRenderProps { + value: T; + setValue: (updater: T | ((previous: T) => T)) => void; + isDirty: boolean; + isSaving: boolean; + error: string | null; +} + +interface SettingsFormProps { + initialValue: T; + onSave: (value: T) => Promise; + children: (props: SettingsFormRenderProps) => ReactNode; + isEqual?: (a: T, b: T) => boolean; +} + +function defaultIsEqual(a: T, b: T): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** + * Generic settings framework used by every module settings page: tracks a + * local draft against the last-saved baseline, shows a sticky save bar while + * dirty, and surfaces inline + toast errors on save failure. + */ +export function SettingsForm({ initialValue, onSave, children, isEqual = defaultIsEqual }: SettingsFormProps) { + const t = useTranslations(); + const [baseline, setBaseline] = useState(initialValue); + const [value, setValue] = useState(initialValue); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const isDirty = !isEqual(baseline, value); + + const handleDiscard = useCallback(() => { + setValue(baseline); + setError(null); + }, [baseline]); + + const handleSave = useCallback(() => { + setError(null); + startTransition(async () => { + try { + const result = await onSave(value); + if (result.ok) { + setBaseline(value); + toast.success(t('common.saveSuccess')); + } else { + setError(result.error); + toast.error(result.error || t('common.saveError')); + } + } catch { + setError(t('common.saveError')); + toast.error(t('common.saveError')); + } + }); + }, [onSave, t, value]); + + return ( +
+ {children({ value, setValue, isDirty, isSaving: isPending, error })} + +
+ ); +} diff --git a/apps/webui/src/components/ui/avatar.tsx b/apps/webui/src/components/ui/avatar.tsx new file mode 100644 index 0000000..6d8c31a --- /dev/null +++ b/apps/webui/src/components/ui/avatar.tsx @@ -0,0 +1,43 @@ +'use client'; + +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/webui/src/components/ui/badge.tsx b/apps/webui/src/components/ui/badge.tsx new file mode 100644 index 0000000..56340f3 --- /dev/null +++ b/apps/webui/src/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground', + secondary: 'border-transparent bg-secondary text-secondary-foreground', + outline: 'border-border text-foreground', + success: 'border-transparent bg-emerald-500/15 text-emerald-500', + destructive: 'border-transparent bg-destructive/15 text-destructive' + } + }, + defaultVariants: { + variant: 'default' + } + } +); + +export interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ; +} + +export { Badge, badgeVariants }; diff --git a/apps/webui/src/components/ui/button.tsx b/apps/webui/src/components/ui/button.tsx new file mode 100644 index 0000000..30ba491 --- /dev/null +++ b/apps/webui/src/components/ui/button.tsx @@ -0,0 +1,53 @@ +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline' + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-6', + icon: 'size-9' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + } +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/apps/webui/src/components/ui/card.tsx b/apps/webui/src/components/ui/card.tsx new file mode 100644 index 0000000..f90e7e7 --- /dev/null +++ b/apps/webui/src/components/ui/card.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.HTMLAttributes) { + return ( +

+ ); +} + +function CardDescription({ className, ...props }: React.HTMLAttributes) { + return ( +

+ ); +} + +function CardContent({ className, ...props }: React.HTMLAttributes) { + return

; +} + +function CardFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; diff --git a/apps/webui/src/components/ui/dropdown-menu.tsx b/apps/webui/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..b744de3 --- /dev/null +++ b/apps/webui/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,175 @@ +'use client'; + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: 'default' | 'destructive'; + } +>(({ className, inset, variant = 'default', ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +function DropdownMenuShortcut({ className, ...props }: React.HTMLAttributes) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup +}; diff --git a/apps/webui/src/components/ui/input.tsx b/apps/webui/src/components/ui/input.tsx new file mode 100644 index 0000000..06f5ec3 --- /dev/null +++ b/apps/webui/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ); +}); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/webui/src/components/ui/label.tsx b/apps/webui/src/components/ui/label.tsx new file mode 100644 index 0000000..bc385a4 --- /dev/null +++ b/apps/webui/src/components/ui/label.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as LabelPrimitive from '@radix-ui/react-label'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/webui/src/components/ui/select.tsx b/apps/webui/src/components/ui/select.tsx new file mode 100644 index 0000000..42cb9f2 --- /dev/null +++ b/apps/webui/src/components/ui/select.tsx @@ -0,0 +1,148 @@ +'use client'; + +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton +}; diff --git a/apps/webui/src/components/ui/separator.tsx b/apps/webui/src/components/ui/separator.tsx new file mode 100644 index 0000000..77aab57 --- /dev/null +++ b/apps/webui/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +'use client'; + +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/webui/src/components/ui/skeleton.tsx b/apps/webui/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..0d0a51d --- /dev/null +++ b/apps/webui/src/components/ui/skeleton.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/apps/webui/src/components/ui/switch.tsx b/apps/webui/src/components/ui/switch.tsx new file mode 100644 index 0000000..26f31ea --- /dev/null +++ b/apps/webui/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +'use client'; + +import * as SwitchPrimitive from '@radix-ui/react-switch'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitive.Root.displayName; + +export { Switch }; diff --git a/apps/webui/src/lib/access-rules.ts b/apps/webui/src/lib/access-rules.ts new file mode 100644 index 0000000..936a529 --- /dev/null +++ b/apps/webui/src/lib/access-rules.ts @@ -0,0 +1,38 @@ +import type { Prisma } from '@prisma/client'; +import type { DashboardAccessRule } from '@nexumi/shared'; +import { prisma } from './prisma'; + +export async function listAccessRules(guildId: string): Promise { + const rules = await prisma.dashboardAccessRule.findMany({ + where: { guildId }, + orderBy: { createdAt: 'asc' } + }); + + return rules.map((rule) => ({ + id: rule.id, + roleId: rule.roleId, + modules: rule.modules as DashboardAccessRule['modules'] + })); +} + +export async function replaceAccessRules( + guildId: string, + rules: DashboardAccessRule[] +): Promise { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); + + await prisma.$transaction([ + prisma.dashboardAccessRule.deleteMany({ where: { guildId } }), + ...rules.map((rule) => + prisma.dashboardAccessRule.create({ + data: { + guildId, + roleId: rule.roleId, + modules: rule.modules as Prisma.InputJsonValue + } + }) + ) + ]); + + return listAccessRules(guildId); +} diff --git a/apps/webui/src/lib/audit.ts b/apps/webui/src/lib/audit.ts new file mode 100644 index 0000000..bc261cb --- /dev/null +++ b/apps/webui/src/lib/audit.ts @@ -0,0 +1,55 @@ +import { Prisma, type PrismaClient } from '@prisma/client'; +import type { DashboardAuditEntry } from '@nexumi/shared'; +import { prisma } from './prisma'; + +export interface WriteDashboardAuditInput { + guildId?: string | null; + actorUserId: string; + action: string; + path?: string | null; + before?: unknown; + after?: unknown; +} + +function toJsonInput(value: unknown): Prisma.InputJsonValue | undefined { + if (value === undefined) { + return undefined; + } + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} + +export async function writeDashboardAudit( + prisma: PrismaClient, + input: WriteDashboardAuditInput +): Promise { + await prisma.dashboardAuditLog.create({ + data: { + guildId: input.guildId ?? null, + actorUserId: input.actorUserId, + action: input.action, + path: input.path ?? null, + before: toJsonInput(input.before) ?? Prisma.JsonNull, + after: toJsonInput(input.after) ?? Prisma.JsonNull + } + }); +} + +export async function listRecentDashboardAudit( + guildId: string, + limit = 10 +): Promise { + const entries = await prisma.dashboardAuditLog.findMany({ + where: { guildId }, + orderBy: { createdAt: 'desc' }, + take: limit + }); + + return entries.map((entry) => ({ + id: entry.id, + guildId: entry.guildId, + actorUserId: entry.actorUserId, + action: entry.action, + path: entry.path, + createdAt: entry.createdAt.toISOString() + })); +} diff --git a/apps/webui/src/lib/auth.ts b/apps/webui/src/lib/auth.ts new file mode 100644 index 0000000..6310cc8 --- /dev/null +++ b/apps/webui/src/lib/auth.ts @@ -0,0 +1,99 @@ +import { redirect } from 'next/navigation'; +import { NextResponse } from 'next/server'; +import { ZodError } from 'zod'; +import { hasManageGuildPermission } from '@nexumi/shared'; +import { fetchDiscordUserGuildsCached } from './discord-oauth'; +import { prisma } from './prisma'; +import { getSession, type SessionPayload } from './session'; + +export class UnauthorizedError extends Error { + constructor(message = 'Authentication required') { + super(message); + this.name = 'UnauthorizedError'; + } +} + +export class ForbiddenError extends Error { + constructor(message = 'You do not have access to this resource') { + super(message); + this.name = 'ForbiddenError'; + } +} + +/** + * Use in API routes. Throws {@link UnauthorizedError} when no session exists. + */ +export async function requireAuth(): Promise { + const session = await getSession(); + if (!session) { + throw new UnauthorizedError(); + } + return session; +} + +/** + * Use in Server Components / layouts. Redirects to /login instead of throwing, + * since there is no JSON response to return. + */ +export async function requireAuthOrRedirect(): Promise { + const session = await getSession(); + if (!session) { + redirect('/login'); + } + return session; +} + +/** + * Ensures the current session user has Manage Guild permission on the given + * guild (via Discord OAuth guild list) and that the bot is actually installed + * there (tracked via the `Guild` table). Use in API routes. + */ +export async function requireGuildAccess(guildId: string): Promise { + const session = await requireAuth(); + const guilds = await fetchDiscordUserGuildsCached(session.accessToken); + const guild = guilds.find((entry) => entry.id === guildId); + if (!guild || !hasManageGuildPermission(guild.permissions)) { + throw new ForbiddenError('Missing Manage Guild permission for this server'); + } + const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } }); + if (!dbGuild) { + throw new ForbiddenError('Nexumi is not installed on this server'); + } + return session; +} + +/** + * Same as {@link requireGuildAccess} but for Server Components: redirects + * instead of throwing. + */ +export async function requireGuildAccessOrRedirect(guildId: string): Promise { + const session = await requireAuthOrRedirect(); + const guilds = await fetchDiscordUserGuildsCached(session.accessToken); + const guild = guilds.find((entry) => entry.id === guildId); + if (!guild || !hasManageGuildPermission(guild.permissions)) { + redirect('/dashboard'); + } + const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } }); + if (!dbGuild) { + redirect('/dashboard'); + } + return session; +} + +export function toApiErrorResponse(error: unknown): NextResponse { + if (error instanceof UnauthorizedError) { + return NextResponse.json({ error: error.message }, { status: 401 }); + } + if (error instanceof ForbiddenError) { + return NextResponse.json({ error: error.message }, { status: 403 }); + } + if (error instanceof ZodError) { + return NextResponse.json( + { error: 'Validation failed', issues: error.issues }, + { status: 400 } + ); + } + // eslint-disable-next-line no-console + console.error('[api]', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); +} diff --git a/apps/webui/src/lib/discord-oauth.ts b/apps/webui/src/lib/discord-oauth.ts new file mode 100644 index 0000000..f9a5fa6 --- /dev/null +++ b/apps/webui/src/lib/discord-oauth.ts @@ -0,0 +1,115 @@ +import { createHash } from 'crypto'; +import { env } from './env'; +import { redis } from './redis'; + +const DISCORD_API_BASE = 'https://discord.com/api/v10'; +const OAUTH_SCOPES = ['identify', 'guilds'] as const; +const USER_GUILDS_CACHE_TTL_SECONDS = 30; + +export function getRedirectUri(): string { + return `${env.WEBUI_URL}/api/auth/callback`; +} + +export function buildAuthorizeUrl(state: string): string { + const url = new URL('https://discord.com/api/oauth2/authorize'); + url.searchParams.set('client_id', env.BOT_CLIENT_ID); + url.searchParams.set('redirect_uri', getRedirectUri()); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', OAUTH_SCOPES.join(' ')); + url.searchParams.set('state', state); + url.searchParams.set('prompt', 'consent'); + return url.toString(); +} + +export interface DiscordTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +} + +export async function exchangeCodeForToken(code: string): Promise { + const body = new URLSearchParams({ + client_id: env.BOT_CLIENT_ID, + client_secret: env.BOT_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: getRedirectUri() + }); + + const response = await fetch(`${DISCORD_API_BASE}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + cache: 'no-store' + }); + + if (!response.ok) { + throw new Error(`Discord token exchange failed with status ${response.status}`); + } + + return (await response.json()) as DiscordTokenResponse; +} + +export interface DiscordUser { + id: string; + username: string; + global_name: string | null; + avatar: string | null; + locale?: string; +} + +export async function fetchDiscordUser(accessToken: string): Promise { + const response = await fetch(`${DISCORD_API_BASE}/users/@me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + cache: 'no-store' + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Discord user with status ${response.status}`); + } + + return (await response.json()) as DiscordUser; +} + +export interface DiscordUserGuild { + id: string; + name: string; + icon: string | null; + owner: boolean; + permissions: string; +} + +export async function fetchDiscordUserGuilds(accessToken: string): Promise { + const response = await fetch(`${DISCORD_API_BASE}/users/@me/guilds`, { + headers: { Authorization: `Bearer ${accessToken}` }, + cache: 'no-store' + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Discord guilds with status ${response.status}`); + } + + return (await response.json()) as DiscordUserGuild[]; +} + +function guildsCacheKey(accessToken: string): string { + return `webui:discordguilds:${createHash('sha256').update(accessToken).digest('hex')}`; +} + +/** + * Cached variant of {@link fetchDiscordUserGuilds} to avoid hitting Discord's + * rate limits when a single dashboard page load needs the guild list more + * than once (e.g. layout + page + server switcher). + */ +export async function fetchDiscordUserGuildsCached(accessToken: string): Promise { + const cacheKey = guildsCacheKey(accessToken); + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as DiscordUserGuild[]; + } + const guilds = await fetchDiscordUserGuilds(accessToken); + await redis.set(cacheKey, JSON.stringify(guilds), 'EX', USER_GUILDS_CACHE_TTL_SECONDS); + return guilds; +} diff --git a/apps/webui/src/lib/env.ts b/apps/webui/src/lib/env.ts new file mode 100644 index 0000000..9f24901 --- /dev/null +++ b/apps/webui/src/lib/env.ts @@ -0,0 +1,19 @@ +import { config } from 'dotenv'; +import { z } from 'zod'; + +config(); + +const EnvSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + DATABASE_URL: z.string().url(), + REDIS_URL: z.string().url(), + BOT_CLIENT_ID: z.string().min(1), + BOT_CLIENT_SECRET: z.string().min(1), + WEBUI_URL: z.string().url().default('http://localhost:3000'), + SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'), + SENTRY_DSN: z.string().optional(), + DEFAULT_LOCALE: z.enum(['de', 'en']).default('de') +}); + +export const env = EnvSchema.parse(process.env); +export type Env = typeof env; diff --git a/apps/webui/src/lib/guild-settings.ts b/apps/webui/src/lib/guild-settings.ts new file mode 100644 index 0000000..5d9c8e4 --- /dev/null +++ b/apps/webui/src/lib/guild-settings.ts @@ -0,0 +1,41 @@ +import type { GuildGeneralSettings, GuildGeneralSettingsPatch } from '@nexumi/shared'; +import { prisma } from './prisma'; + +async function ensureGuildSettings(guildId: string) { + await prisma.guild.upsert({ + where: { id: guildId }, + update: {}, + create: { id: guildId } + }); + + return prisma.guildSettings.upsert({ + where: { guildId }, + update: {}, + create: { guildId } + }); +} + +function toGeneralSettings(settings: { locale: string; timezone: string; moderationEnabled: boolean }): GuildGeneralSettings { + return { + locale: settings.locale === 'en' ? 'en' : 'de', + timezone: settings.timezone, + moderationEnabled: settings.moderationEnabled + }; +} + +export async function getGuildGeneralSettings(guildId: string): Promise { + const settings = await ensureGuildSettings(guildId); + return toGeneralSettings(settings); +} + +export async function updateGuildGeneralSettings( + guildId: string, + patch: GuildGeneralSettingsPatch +): Promise { + await ensureGuildSettings(guildId); + const updated = await prisma.guildSettings.update({ + where: { guildId }, + data: patch + }); + return toGeneralSettings(updated); +} diff --git a/apps/webui/src/lib/guilds.ts b/apps/webui/src/lib/guilds.ts new file mode 100644 index 0000000..b26159f --- /dev/null +++ b/apps/webui/src/lib/guilds.ts @@ -0,0 +1,51 @@ +import { hasManageGuildPermission, type DashboardGuild } from '@nexumi/shared'; +import { fetchDiscordUserGuildsCached } from './discord-oauth'; +import { prisma } from './prisma'; +import type { SessionPayload } from './session'; + +/** + * Returns the guilds the current session user can manage (Manage Guild + * permission), enriched with whether Nexumi is already installed there. + * Guilds where the bot is present are sorted first. + */ +export async function getManageableGuilds(session: SessionPayload): Promise { + const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken); + const manageable = discordGuilds.filter((guild) => hasManageGuildPermission(guild.permissions)); + + if (manageable.length === 0) { + return []; + } + + const presentGuilds = await prisma.guild.findMany({ + where: { id: { in: manageable.map((guild) => guild.id) } }, + select: { id: true } + }); + const presentIds = new Set(presentGuilds.map((guild) => guild.id)); + + return manageable + .map((guild) => ({ + id: guild.id, + name: guild.name, + icon: guild.icon, + botPresent: presentIds.has(guild.id), + permissions: guild.permissions + })) + .sort((a, b) => { + if (a.botPresent !== b.botPresent) { + return a.botPresent ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); +} + +/** + * Looks up a single manageable guild by id, or null when the session user + * does not manage it. + */ +export async function getManageableGuild( + session: SessionPayload, + guildId: string +): Promise { + const guilds = await getManageableGuilds(session); + return guilds.find((guild) => guild.id === guildId) ?? null; +} diff --git a/apps/webui/src/lib/i18n.ts b/apps/webui/src/lib/i18n.ts new file mode 100644 index 0000000..a635108 --- /dev/null +++ b/apps/webui/src/lib/i18n.ts @@ -0,0 +1,54 @@ +import { cookies } from 'next/headers'; +import deMessages from '@/messages/de.json'; +import enMessages from '@/messages/en.json'; +import { env } from './env'; + +export type Locale = 'de' | 'en'; + +export const LOCALE_COOKIE_NAME = 'nexumi_locale'; + +export type Messages = typeof enMessages; + +export const MESSAGES: Record = { + de: deMessages, + en: enMessages +}; + +function resolve(messages: Messages, key: string): string | undefined { + const value = key.split('.').reduce((acc, part) => { + if (acc && typeof acc === 'object' && part in (acc as Record)) { + return (acc as Record)[part]; + } + return undefined; + }, messages); + return typeof value === 'string' ? value : undefined; +} + +function interpolate(template: string, vars?: Record): string { + if (!vars) { + return template; + } + return Object.entries(vars).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + template + ); +} + +export function t(locale: Locale, key: string, vars?: Record): string { + const dict = MESSAGES[locale] ?? MESSAGES[env.DEFAULT_LOCALE]; + return interpolate(resolve(dict, key) ?? key, vars); +} + +function isLocale(value: string | undefined): value is Locale { + return value === 'de' || value === 'en'; +} + +/** + * Resolves the active locale for the current request from the + * `nexumi_locale` cookie, falling back to the server's configured default. + */ +export async function getLocale(): Promise { + const cookieStore = await cookies(); + const cookieLocale = cookieStore.get(LOCALE_COOKIE_NAME)?.value; + return isLocale(cookieLocale) ? cookieLocale : env.DEFAULT_LOCALE; +} diff --git a/apps/webui/src/lib/modules.ts b/apps/webui/src/lib/modules.ts new file mode 100644 index 0000000..bc19754 --- /dev/null +++ b/apps/webui/src/lib/modules.ts @@ -0,0 +1,253 @@ +import { DASHBOARD_MODULES, type DashboardModuleId, type ModuleStatus } from '@nexumi/shared'; +import { prisma } from './prisma'; +import { redis } from './redis'; + +/** + * Modules that have no dedicated "enabled" column on any Prisma model. + * Their dashboard toggle state is tracked in a Redis hash instead. + */ +const REDIS_ONLY_MODULES = new Set([ + 'giveaways', + 'tags', + 'selfroles', + 'feeds', + 'scheduler', + 'guildbackup' +]); + +function redisModulesKey(guildId: string): string { + return `dashboard:modules:${guildId}`; +} + +function funConfigKey(guildId: string): string { + return `fun:config:${guildId}`; +} + +interface FunConfigLike { + enabled: boolean; + mediaEnabled: boolean; +} + +async function getRedisModuleFlags(guildId: string): Promise>> { + const raw = await redis.hgetall(redisModulesKey(guildId)); + const result: Partial> = {}; + for (const [moduleId, value] of Object.entries(raw)) { + result[moduleId as DashboardModuleId] = value === 'true'; + } + return result; +} + +async function setRedisModuleFlag(guildId: string, moduleId: DashboardModuleId, enabled: boolean): Promise { + await redis.hset(redisModulesKey(guildId), moduleId, enabled ? 'true' : 'false'); +} + +async function getFunConfig(guildId: string): Promise { + const raw = await redis.get(funConfigKey(guildId)); + if (!raw) { + return { enabled: true, mediaEnabled: true }; + } + try { + const parsed = JSON.parse(raw) as { enabled?: boolean; mediaEnabled?: boolean }; + return { enabled: parsed.enabled ?? true, mediaEnabled: parsed.mediaEnabled ?? true }; + } catch { + return { enabled: true, mediaEnabled: true }; + } +} + +async function setFunConfigEnabled(guildId: string, enabled: boolean): Promise { + const current = await getFunConfig(guildId); + await redis.set(funConfigKey(guildId), JSON.stringify({ ...current, enabled })); +} + +/** + * Aggregates the enabled/disabled status of every dashboard module for a + * guild, reading from each module's Prisma config table (when it has an + * `enabled` column), the Fun module's Redis config, or the generic + * `dashboard:modules:{guildId}` Redis hash for modules without any + * dedicated storage yet. + */ +export async function getModuleStatuses(guildId: string): Promise { + const [ + guildSettings, + autoModConfig, + loggingConfig, + welcomeConfig, + verificationConfig, + levelingConfig, + economyConfig, + funConfig, + ticketConfig, + starboardConfig, + suggestionConfig, + birthdayConfig, + tempVoiceConfig, + statsConfig, + redisFlags + ] = await Promise.all([ + prisma.guildSettings.findUnique({ where: { guildId } }), + prisma.autoModConfig.findUnique({ where: { guildId } }), + prisma.loggingConfig.findUnique({ where: { guildId } }), + prisma.welcomeConfig.findUnique({ where: { guildId } }), + prisma.verificationConfig.findUnique({ where: { guildId } }), + prisma.levelingConfig.findUnique({ where: { guildId } }), + prisma.economyConfig.findUnique({ where: { guildId } }), + getFunConfig(guildId), + prisma.ticketConfig.findUnique({ where: { guildId } }), + prisma.starboardConfig.findUnique({ where: { guildId } }), + prisma.suggestionConfig.findUnique({ where: { guildId } }), + prisma.birthdayConfig.findUnique({ where: { guildId } }), + prisma.tempVoiceConfig.findUnique({ where: { guildId } }), + prisma.statsConfig.findUnique({ where: { guildId } }), + getRedisModuleFlags(guildId) + ]); + + const enabledById: Record = { + moderation: guildSettings?.moderationEnabled ?? true, + automod: autoModConfig?.enabled ?? true, + logging: loggingConfig?.enabled ?? true, + welcome: Boolean(welcomeConfig?.welcomeEnabled || welcomeConfig?.leaveEnabled), + verification: verificationConfig?.enabled ?? false, + leveling: levelingConfig?.enabled ?? true, + economy: economyConfig?.enabled ?? true, + fun: funConfig.enabled, + giveaways: redisFlags.giveaways ?? true, + tickets: ticketConfig?.enabled ?? true, + selfroles: redisFlags.selfroles ?? true, + tags: redisFlags.tags ?? true, + starboard: starboardConfig?.enabled ?? false, + suggestions: suggestionConfig?.enabled ?? true, + birthdays: birthdayConfig?.enabled ?? true, + tempvoice: tempVoiceConfig?.enabled ?? false, + stats: statsConfig?.enabled ?? false, + feeds: redisFlags.feeds ?? true, + scheduler: redisFlags.scheduler ?? true, + guildbackup: redisFlags.guildbackup ?? true + }; + + return DASHBOARD_MODULES.map((module) => ({ + id: module.id, + group: module.group, + href: module.href, + enabled: enabledById[module.id] + })); +} + +async function setModuleEnabled(guildId: string, moduleId: DashboardModuleId, enabled: boolean): Promise { + if (REDIS_ONLY_MODULES.has(moduleId)) { + await setRedisModuleFlag(guildId, moduleId, enabled); + return; + } + + switch (moduleId) { + case 'moderation': + await prisma.guildSettings.upsert({ + where: { guildId }, + update: { moderationEnabled: enabled }, + create: { guildId, moderationEnabled: enabled } + }); + return; + case 'automod': + await prisma.autoModConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'logging': + await prisma.loggingConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'welcome': + await prisma.welcomeConfig.upsert({ + where: { guildId }, + update: { welcomeEnabled: enabled, leaveEnabled: enabled }, + create: { guildId, welcomeEnabled: enabled, leaveEnabled: enabled } + }); + return; + case 'verification': + await prisma.verificationConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'leveling': + await prisma.levelingConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'economy': + await prisma.economyConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'fun': + await setFunConfigEnabled(guildId, enabled); + return; + case 'tickets': + await prisma.ticketConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'starboard': + await prisma.starboardConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'suggestions': + await prisma.suggestionConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'birthdays': + await prisma.birthdayConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'tempvoice': + await prisma.tempVoiceConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + case 'stats': + await prisma.statsConfig.upsert({ + where: { guildId }, + update: { enabled }, + create: { guildId, enabled } + }); + return; + default: + throw new Error(`Unknown or unsupported module: ${String(moduleId)}`); + } +} + +export async function updateModuleStatuses( + guildId: string, + updates: Partial> +): Promise { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); + + for (const [moduleId, enabled] of Object.entries(updates)) { + if (enabled === undefined) continue; + await setModuleEnabled(guildId, moduleId as DashboardModuleId, enabled); + } + + return getModuleStatuses(guildId); +} diff --git a/apps/webui/src/lib/prisma.ts b/apps/webui/src/lib/prisma.ts new file mode 100644 index 0000000..01e2ed0 --- /dev/null +++ b/apps/webui/src/lib/prisma.ts @@ -0,0 +1,10 @@ +import { PrismaClient } from '@prisma/client'; +import { env } from './env'; + +const globalForPrisma = globalThis as unknown as { __nexumiPrisma?: PrismaClient }; + +export const prisma: PrismaClient = globalForPrisma.__nexumiPrisma ?? new PrismaClient(); + +if (env.NODE_ENV !== 'production') { + globalForPrisma.__nexumiPrisma = prisma; +} diff --git a/apps/webui/src/lib/redis.ts b/apps/webui/src/lib/redis.ts new file mode 100644 index 0000000..9bdc4fc --- /dev/null +++ b/apps/webui/src/lib/redis.ts @@ -0,0 +1,12 @@ +import Redis from 'ioredis'; +import { env } from './env'; + +const globalForRedis = globalThis as unknown as { __nexumiRedis?: Redis }; + +export const redis: Redis = globalForRedis.__nexumiRedis ?? new Redis(env.REDIS_URL, { + maxRetriesPerRequest: 3 +}); + +if (env.NODE_ENV !== 'production') { + globalForRedis.__nexumiRedis = redis; +} diff --git a/apps/webui/src/lib/session.ts b/apps/webui/src/lib/session.ts new file mode 100644 index 0000000..fa2bc5e --- /dev/null +++ b/apps/webui/src/lib/session.ts @@ -0,0 +1,105 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import { cookies } from 'next/headers'; +import { z } from 'zod'; +import { SessionUserSchema } from '@nexumi/shared'; +import { env } from './env'; +import { redis } from './redis'; + +export const SESSION_COOKIE_NAME = 'nexumi_session'; +const SESSION_KEY_PREFIX = 'webui:session:'; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; + +const SessionPayloadSchema = z.object({ + user: SessionUserSchema, + accessToken: z.string().min(1), + tokenExpiresAt: z.number().optional() +}); + +export type SessionPayload = z.infer; + +export class SessionRequiredError extends Error { + constructor() { + super('Session required'); + this.name = 'SessionRequiredError'; + } +} + +function sign(value: string): string { + return createHmac('sha256', env.SESSION_SECRET).update(value).digest('hex'); +} + +function buildCookieValue(sessionId: string): string { + return `${sessionId}.${sign(sessionId)}`; +} + +function parseCookieValue(cookieValue: string): string | null { + const separatorIndex = cookieValue.lastIndexOf('.'); + if (separatorIndex <= 0) { + return null; + } + const sessionId = cookieValue.slice(0, separatorIndex); + const signature = cookieValue.slice(separatorIndex + 1); + const expected = sign(sessionId); + const provided = Buffer.from(signature); + const expectedBuffer = Buffer.from(expected); + if (provided.length !== expectedBuffer.length || !timingSafeEqual(provided, expectedBuffer)) { + return null; + } + return sessionId; +} + +function sessionRedisKey(sessionId: string): string { + return `${SESSION_KEY_PREFIX}${sessionId}`; +} + +export async function createSession(payload: SessionPayload): Promise { + const sessionId = randomBytes(32).toString('hex'); + await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS); + + const cookieStore = await cookies(); + cookieStore.set(SESSION_COOKIE_NAME, buildCookieValue(sessionId), { + httpOnly: true, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + path: '/', + maxAge: SESSION_TTL_SECONDS + }); +} + +export async function getSession(): Promise { + const cookieStore = await cookies(); + const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value; + if (!raw) { + return null; + } + const sessionId = parseCookieValue(raw); + if (!sessionId) { + return null; + } + const stored = await redis.get(sessionRedisKey(sessionId)); + if (!stored) { + return null; + } + const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored)); + return parsed.success ? parsed.data : null; +} + +export async function destroySession(): Promise { + const cookieStore = await cookies(); + const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value; + if (raw) { + const sessionId = parseCookieValue(raw); + if (sessionId) { + await redis.del(sessionRedisKey(sessionId)); + } + } + cookieStore.delete(SESSION_COOKIE_NAME); +} + +export async function requireSession(): Promise { + const session = await getSession(); + if (!session) { + throw new SessionRequiredError(); + } + return session; +} diff --git a/apps/webui/src/lib/utils.test.ts b/apps/webui/src/lib/utils.test.ts new file mode 100644 index 0000000..11be856 --- /dev/null +++ b/apps/webui/src/lib/utils.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { cn } from './utils'; + +describe('cn', () => { + it('merges class names', () => { + expect(cn('flex', 'items-center')).toBe('flex items-center'); + }); + + it('resolves conflicting tailwind classes in favor of the last one', () => { + expect(cn('px-2', 'px-4')).toBe('px-4'); + }); + + it('drops falsy values', () => { + expect(cn('base', false && 'hidden', undefined, null, 'visible')).toBe('base visible'); + }); +}); diff --git a/apps/webui/src/lib/utils.ts b/apps/webui/src/lib/utils.ts new file mode 100644 index 0000000..a7c2663 --- /dev/null +++ b/apps/webui/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json new file mode 100644 index 0000000..a2a6624 --- /dev/null +++ b/apps/webui/src/messages/de.json @@ -0,0 +1,143 @@ +{ + "common": { + "save": "Speichern", + "saving": "Speichert…", + "discard": "Verwerfen", + "cancel": "Abbrechen", + "confirm": "Bestätigen", + "add": "Hinzufügen", + "remove": "Entfernen", + "loading": "Lädt…", + "yes": "Ja", + "no": "Nein", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "unsavedChanges": "Du hast ungespeicherte Änderungen", + "saveSuccess": "Änderungen gespeichert", + "saveError": "Änderungen konnten nicht gespeichert werden", + "back": "Zurück", + "comingSoon": "Demnächst verfügbar", + "close": "Schließen" + }, + "nav": { + "dashboard": "Dashboard", + "overview": "Übersicht", + "settings": "Einstellungen", + "modules": "Module", + "access": "Dashboard-Zugriff" + }, + "moduleGroups": { + "moderation": "Moderation", + "engagement": "Engagement", + "utility": "Utility", + "community": "Community", + "integrations": "Integrationen" + }, + "modules": { + "moderation": { "label": "Moderation", "description": "Bans, Verwarnungen, Timeouts und das Fall-System" }, + "automod": { "label": "Auto-Moderation", "description": "Spam-, Raid- und Nuke-Schutz" }, + "logging": { "label": "Logging", "description": "Log-Kanäle für Server-Events" }, + "welcome": { "label": "Willkommen & Abschied", "description": "Join- und Leave-Nachrichten, Autoroles" }, + "verification": { "label": "Verifizierung", "description": "Button- und Captcha-Verifizierung" }, + "leveling": { "label": "Leveling & XP", "description": "Text- und Voice-XP, Rollen-Belohnungen" }, + "economy": { "label": "Economy", "description": "Währung, Shop und Spiele" }, + "fun": { "label": "Fun & Spiele", "description": "Minispiele und Medien-Commands" }, + "giveaways": { "label": "Giveaways", "description": "Button-basierte Giveaways" }, + "tickets": { "label": "Tickets", "description": "Support-Ticket-Panels und Transkripte" }, + "selfroles": { "label": "Self Roles", "description": "Reaktions-, Button- und Dropdown-Rollenpanels" }, + "tags": { "label": "Tags", "description": "Custom Commands und Auto-Responder" }, + "starboard": { "label": "Starboard", "description": "Beliebte Nachrichten hervorheben" }, + "suggestions": { "label": "Vorschläge", "description": "Community-Vorschläge mit Abstimmung" }, + "birthdays": { "label": "Geburtstage", "description": "Geburtstags-Ankündigungen und -Rollen" }, + "tempvoice": { "label": "Temp-Voice", "description": "Join-to-Create Voice-Kanäle" }, + "stats": { "label": "Server-Statistiken", "description": "Automatische Statistik-Kanäle und Invites" }, + "feeds": { "label": "Social Feeds", "description": "Twitch-, YouTube-, RSS- und Reddit-Benachrichtigungen" }, + "scheduler": { "label": "Scheduler", "description": "Geplante und wiederkehrende Ankündigungen" }, + "guildbackup": { "label": "Backups", "description": "Server-Backup und -Wiederherstellung" } + }, + "login": { + "title": "Nexumi", + "subtitle": "Melde dich mit Discord an, um deine Server zu verwalten.", + "cta": "Mit Discord anmelden", + "footer": "Mit der Anmeldung akzeptierst du die Nexumi-Nutzungsbedingungen und Datenschutzerklärung.", + "errors": { + "access_denied": "Du hast die Discord-Anmeldung abgebrochen.", + "invalid_request": "Ungültige Anmeldeanfrage. Bitte erneut versuchen.", + "invalid_state": "Deine Anmelde-Sitzung ist abgelaufen. Bitte erneut versuchen.", + "oauth_failed": "Die Discord-Anmeldung ist fehlgeschlagen. Bitte erneut versuchen." + } + }, + "dashboard": { + "guildList": { + "title": "Deine Server", + "subtitle": "Wähle einen Server zur Verwaltung oder lade Nexumi auf einen neuen Server ein.", + "manage": "Verwalten", + "invite": "Einladen", + "botMissing": "Nicht installiert", + "empty": "Keine Server gefunden. Du brauchst die Berechtigung „Server verwalten“ auf einem Discord-Server, um hier zu erscheinen." + }, + "overview": { + "title": "Übersicht", + "moduleStatusTitle": "Modul-Status", + "moduleStatusEmpty": "Noch keine Modul-Daten vorhanden.", + "recentAuditTitle": "Letzte Dashboard-Aktivität", + "auditEmpty": "Noch keine Dashboard-Änderungen.", + "auditPath": "Pfad", + "viewAllModules": "Module verwalten" + } + }, + "settings": { + "general": { + "title": "Allgemeine Einstellungen", + "description": "Sprache, Zeitzone und der Moderations-Modul-Schalter für diesen Server.", + "localeLabel": "Dashboard- & Bot-Sprache", + "localeHint": "Wird für Bot-Antworten und Dashboard-Standardwerte auf diesem Server verwendet.", + "timezoneLabel": "Zeitzone", + "timezoneHint": "IANA-Zeitzonen-Bezeichner, z. B. Europe/Berlin oder America/New_York.", + "moderationLabel": "Moderations-Modul", + "moderationHint": "Aktiviert Moderations-Commands (Ban, Kick, Warn, Purge, …) auf diesem Server." + } + }, + "modulesPage": { + "title": "Module", + "description": "Aktiviere oder deaktiviere einzelne Nexumi-Module für diesen Server.", + "configure": "Konfigurieren" + }, + "access": { + "title": "Dashboard-Zugriff", + "description": "Gewähre zusätzlichen Discord-Rollen Zugriff auf bestimmte Dashboard-Bereiche, zusätzlich zu Server-Administratoren.", + "roleIdLabel": "Rollen-ID", + "roleIdPlaceholder": "z. B. 123456789012345678", + "roleIdHint": "Rechtsklick auf eine Rolle in Discord bei aktiviertem Entwicklermodus und „Rollen-ID kopieren“ wählen.", + "modulesLabel": "Erlaubte Module", + "allModulesOption": "Alle Module", + "specificModulesOption": "Bestimmte Module", + "addRule": "Regel hinzufügen", + "removeRule": "Entfernen", + "noRules": "Keine zusätzlichen Zugriffsregeln konfiguriert. Nur Server-Administratoren können das Dashboard nutzen.", + "invalidRoleId": "Gib eine gültige Discord-Rollen-ID an (17–20 Ziffern)." + }, + "modulePlaceholder": { + "title": "{module}-Einstellungen", + "message": "Modul-Einstellungen kommen in Phase 7.", + "backToModules": "Zurück zu den Modulen" + }, + "userMenu": { + "theme": "Design", + "themeDark": "Dunkel", + "themeLight": "Hell", + "logout": "Abmelden", + "loggingOut": "Meldet ab…" + }, + "serverSwitcher": { + "switchServer": "Server wechseln", + "allServers": "Alle Server", + "noOtherServers": "Keine weiteren verwaltbaren Server" + }, + "errors": { + "unauthorized": "Bitte melde dich an, um fortzufahren.", + "forbidden": "Du hast keinen Zugriff auf diesen Server.", + "notFound": "Nicht gefunden.", + "generic": "Etwas ist schiefgelaufen. Bitte erneut versuchen." + } +} diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json new file mode 100644 index 0000000..95d661f --- /dev/null +++ b/apps/webui/src/messages/en.json @@ -0,0 +1,143 @@ +{ + "common": { + "save": "Save", + "saving": "Saving…", + "discard": "Discard", + "cancel": "Cancel", + "confirm": "Confirm", + "add": "Add", + "remove": "Remove", + "loading": "Loading…", + "yes": "Yes", + "no": "No", + "enabled": "Enabled", + "disabled": "Disabled", + "unsavedChanges": "You have unsaved changes", + "saveSuccess": "Changes saved", + "saveError": "Could not save changes", + "back": "Back", + "comingSoon": "Coming soon", + "close": "Close" + }, + "nav": { + "dashboard": "Dashboard", + "overview": "Overview", + "settings": "Settings", + "modules": "Modules", + "access": "Dashboard access" + }, + "moduleGroups": { + "moderation": "Moderation", + "engagement": "Engagement", + "utility": "Utility", + "community": "Community", + "integrations": "Integrations" + }, + "modules": { + "moderation": { "label": "Moderation", "description": "Bans, warns, timeouts and the case system" }, + "automod": { "label": "Auto-Moderation", "description": "Spam, raid and nuke protection" }, + "logging": { "label": "Logging", "description": "Server event log channels" }, + "welcome": { "label": "Welcome & Leave", "description": "Join and leave messages, autoroles" }, + "verification": { "label": "Verification", "description": "Button and captcha verification" }, + "leveling": { "label": "Leveling & XP", "description": "Text and voice XP, level rewards" }, + "economy": { "label": "Economy", "description": "Currency, shop and games" }, + "fun": { "label": "Fun & Games", "description": "Mini games and media commands" }, + "giveaways": { "label": "Giveaways", "description": "Button-based giveaways" }, + "tickets": { "label": "Tickets", "description": "Support ticket panels and transcripts" }, + "selfroles": { "label": "Self Roles", "description": "Reaction, button and dropdown role panels" }, + "tags": { "label": "Tags", "description": "Custom commands and auto-responders" }, + "starboard": { "label": "Starboard", "description": "Highlight popular messages" }, + "suggestions": { "label": "Suggestions", "description": "Community suggestion voting" }, + "birthdays": { "label": "Birthdays", "description": "Birthday announcements and roles" }, + "tempvoice": { "label": "Temp Voice", "description": "Join-to-create voice channels" }, + "stats": { "label": "Server Stats", "description": "Auto-updating stat channels and invites" }, + "feeds": { "label": "Social Feeds", "description": "Twitch, YouTube, RSS and Reddit notifications" }, + "scheduler": { "label": "Scheduler", "description": "Scheduled and recurring announcements" }, + "guildbackup": { "label": "Backups", "description": "Server backup and restore" } + }, + "login": { + "title": "Nexumi", + "subtitle": "Sign in with Discord to manage your servers.", + "cta": "Login with Discord", + "footer": "By logging in you agree to the Nexumi Terms of Service and Privacy Policy.", + "errors": { + "access_denied": "You cancelled the Discord login.", + "invalid_request": "Invalid login request. Please try again.", + "invalid_state": "Your login session expired. Please try again.", + "oauth_failed": "Discord login failed. Please try again." + } + }, + "dashboard": { + "guildList": { + "title": "Your servers", + "subtitle": "Select a server to manage, or invite Nexumi to a new one.", + "manage": "Manage", + "invite": "Invite", + "botMissing": "Not installed", + "empty": "No servers found. You need the Manage Server permission on a Discord server to appear here." + }, + "overview": { + "title": "Overview", + "moduleStatusTitle": "Module status", + "moduleStatusEmpty": "No module data yet.", + "recentAuditTitle": "Recent dashboard activity", + "auditEmpty": "No dashboard changes yet.", + "auditPath": "Path", + "viewAllModules": "Manage modules" + } + }, + "settings": { + "general": { + "title": "General settings", + "description": "Language, timezone and the moderation module toggle for this server.", + "localeLabel": "Dashboard & bot language", + "localeHint": "Used for bot replies and dashboard defaults on this server.", + "timezoneLabel": "Timezone", + "timezoneHint": "IANA timezone identifier, e.g. Europe/Berlin or America/New_York.", + "moderationLabel": "Moderation module", + "moderationHint": "Enable moderation commands (ban, kick, warn, purge, …) on this server." + } + }, + "modulesPage": { + "title": "Modules", + "description": "Enable or disable individual Nexumi modules for this server.", + "configure": "Configure" + }, + "access": { + "title": "Dashboard access", + "description": "Grant additional Discord roles access to specific dashboard sections, in addition to server administrators.", + "roleIdLabel": "Role ID", + "roleIdPlaceholder": "e.g. 123456789012345678", + "roleIdHint": "Right-click a role in Discord with Developer Mode enabled and choose \"Copy Role ID\".", + "modulesLabel": "Allowed modules", + "allModulesOption": "All modules", + "specificModulesOption": "Specific modules", + "addRule": "Add rule", + "removeRule": "Remove", + "noRules": "No additional access rules configured. Only server administrators can access the dashboard.", + "invalidRoleId": "Enter a valid Discord role ID (17–20 digits)." + }, + "modulePlaceholder": { + "title": "{module} settings", + "message": "Module settings arrive in Phase 7.", + "backToModules": "Back to modules" + }, + "userMenu": { + "theme": "Theme", + "themeDark": "Dark", + "themeLight": "Light", + "logout": "Log out", + "loggingOut": "Logging out…" + }, + "serverSwitcher": { + "switchServer": "Switch server", + "allServers": "All servers", + "noOtherServers": "No other manageable servers" + }, + "errors": { + "unauthorized": "Please log in to continue.", + "forbidden": "You do not have access to this server.", + "notFound": "Not found.", + "generic": "Something went wrong. Please try again." + } +} diff --git a/apps/webui/tsconfig.json b/apps/webui/tsconfig.json index 409bb7e..c133409 100644 --- a/apps/webui/tsconfig.json +++ b/apps/webui/tsconfig.json @@ -10,7 +10,6 @@ "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, - "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true,