diff --git a/apps/bot/prisma/migrations/20260722180000_phase6_webui/migration.sql b/apps/bot/prisma/migrations/20260722180000_phase6_webui/migration.sql new file mode 100644 index 0000000..6eb91de --- /dev/null +++ b/apps/bot/prisma/migrations/20260722180000_phase6_webui/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "GuildSettings" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin'; + +-- CreateTable +CREATE TABLE "DashboardAccessRule" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + "modules" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DashboardAccessRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DashboardAuditLog" ( + "id" TEXT NOT NULL, + "guildId" TEXT, + "actorUserId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "path" TEXT, + "before" JSONB, + "after" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DashboardAuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DashboardAccessRule_guildId_idx" ON "DashboardAccessRule"("guildId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DashboardAccessRule_guildId_roleId_key" ON "DashboardAccessRule"("guildId", "roleId"); + +-- CreateIndex +CREATE INDEX "DashboardAuditLog_guildId_createdAt_idx" ON "DashboardAuditLog"("guildId", "createdAt"); + +-- CreateIndex +CREATE INDEX "DashboardAuditLog_actorUserId_createdAt_idx" ON "DashboardAuditLog"("actorUserId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "DashboardAccessRule" ADD CONSTRAINT "DashboardAccessRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DashboardAuditLog" ADD CONSTRAINT "DashboardAuditLog_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 59df6aa..2411a9c 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -51,16 +51,47 @@ model Guild { socialFeeds SocialFeed[] scheduledMessages ScheduledMessage[] guildBackups GuildBackup[] + dashboardAccessRules DashboardAccessRule[] + dashboardAuditLogs DashboardAuditLog[] } model GuildSettings { id String @id @default(cuid()) guildId String @unique locale String @default("de") + timezone String @default("Europe/Berlin") moderationEnabled Boolean @default(true) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) } +model DashboardAccessRule { + id String @id @default(cuid()) + guildId String + roleId String + modules Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, roleId]) + @@index([guildId]) +} + +model DashboardAuditLog { + id String @id @default(cuid()) + guildId String? + actorUserId String + action String + path String? + before Json? + after Json? + createdAt DateTime @default(now()) + guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId, createdAt]) + @@index([actorUserId, createdAt]) +} + model User { id String @id createdAt DateTime @default(now()) diff --git a/apps/webui/.gitignore b/apps/webui/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/apps/webui/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/webui/README.md b/apps/webui/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/apps/webui/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/webui/eslint.config.mjs b/apps/webui/eslint.config.mjs new file mode 100644 index 0000000..719cea2 --- /dev/null +++ b/apps/webui/eslint.config.mjs @@ -0,0 +1,25 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, +]; + +export default eslintConfig; diff --git a/apps/webui/next.config.ts b/apps/webui/next.config.ts new file mode 100644 index 0000000..1ca2391 --- /dev/null +++ b/apps/webui/next.config.ts @@ -0,0 +1,10 @@ +import path from 'path'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'standalone', + transpilePackages: ['@nexumi/shared'], + outputFileTracingRoot: path.join(__dirname, '../..') +}; + +export default nextConfig; diff --git a/apps/webui/package.json b/apps/webui/package.json new file mode 100644 index 0000000..8c80735 --- /dev/null +++ b/apps/webui/package.json @@ -0,0 +1,50 @@ +{ + "name": "@nexumi/webui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint src --max-warnings=0", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "prisma:generate": "prisma generate --schema=../bot/prisma/schema.prisma" + }, + "dependencies": { + "@nexumi/shared": "workspace:^", + "@prisma/client": "^5.22.0", + "@radix-ui/react-avatar": "^1.2.3", + "@radix-ui/react-dropdown-menu": "^2.1.21", + "@radix-ui/react-label": "^2.1.12", + "@radix-ui/react-select": "^2.3.4", + "@radix-ui/react-separator": "^1.1.12", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-switch": "^1.3.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dotenv": "^16.4.7", + "ioredis": "^5.4.2", + "lucide-react": "^1.25.0", + "next": "15.5.21", + "next-themes": "^0.4.6", + "react": "19.1.0", + "react-dom": "19.1.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.5.21", + "prisma": "^5.22.0", + "tailwindcss": "^4", + "typescript": "^5", + "vitest": "^2.1.8" + } +} diff --git a/apps/webui/postcss.config.mjs b/apps/webui/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/apps/webui/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/apps/webui/public/file.svg b/apps/webui/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/apps/webui/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/webui/public/globe.svg b/apps/webui/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/apps/webui/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/webui/public/next.svg b/apps/webui/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/apps/webui/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/webui/public/vercel.svg b/apps/webui/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/apps/webui/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/webui/public/window.svg b/apps/webui/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/apps/webui/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/webui/src/app/favicon.ico b/apps/webui/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/apps/webui/src/app/favicon.ico differ diff --git a/apps/webui/src/app/globals.css b/apps/webui/src/app/globals.css new file mode 100644 index 0000000..a2dc41e --- /dev/null +++ b/apps/webui/src/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/apps/webui/src/app/layout.tsx b/apps/webui/src/app/layout.tsx new file mode 100644 index 0000000..f7fa87e --- /dev/null +++ b/apps/webui/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/apps/webui/src/app/page.tsx b/apps/webui/src/app/page.tsx new file mode 100644 index 0000000..a932894 --- /dev/null +++ b/apps/webui/src/app/page.tsx @@ -0,0 +1,103 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+
+ Next.js logo +
    +
  1. + Get started by editing{" "} + + src/app/page.tsx + + . +
  2. +
  3. + Save and see your changes instantly. +
  4. +
+ +
+ + Vercel logomark + Deploy now + + + Read our docs + +
+
+ +
+ ); +} diff --git a/apps/webui/tsconfig.json b/apps/webui/tsconfig.json new file mode 100644 index 0000000..409bb7e --- /dev/null +++ b/apps/webui/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 9684c10..5b72668 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -181,7 +181,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede - Manuell auf Test-Server bestätigt (User-Freigabe). -## Phase 5 – Stats/Invites, Feeds, Scheduler, Guild-Backup (Status: implementiert + deployed, manuelle Discord-Tests ausstehend) +## Phase 5 – Stats/Invites, Feeds, Scheduler, Guild-Backup (Status: abgeschlossen, manuell bestätigt) ### Abgeschlossen @@ -191,24 +191,20 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede - Queues/Worker: `stats`, `feeds`, `schedules`, `guild-backups` - Optional: `TWITCH_CLIENT_ID` / `TWITCH_CLIENT_SECRET` - E2E: `docker compose up -d --build bot` OK; Bot ready; 74 Guild-Commands registriert +- Manuell auf Test-Server bestätigt (User-Freigabe) +- Temp-Voice Control-Panel postet in den Voice-Kanal-Chat (statt DM) -### Module +## Phase 6 – WebUI-Fundament (Status: in Arbeit) -| Modul | Features | -|--------|----------| -| **Stats/Invites** | `/stats setup\|refresh`, `/invites view\|leaderboard`, Invite-Tracking, ActivityStat, 10-Min-Kanal-Update | -| **Feeds** | `/feeds add\|remove\|list\|test` (Twitch/YouTube/RSS/Reddit), Poll alle 5 Min | -| **Scheduler** | `/schedule create\|list\|delete`, `/announce`, BullMQ once/cron | -| **Guild-Backup** | `/backup create\|list\|info\|delete\|restore` (Owner + Doppelbestätigung) | +### Geplant -### Manuelle Discord-Tests (offen) - -- `/stats setup` + Kanal-Update; Invite-Join → `/invites view|leaderboard` -- `/feeds add` (RSS/Reddit) + `/feeds test` -- `/schedule create` (once) + `/announce` -- `/backup create|list|info` und Delete/Restore-Bestätigung (Owner) +- Next.js App Router (`apps/webui`), Discord OAuth2, Redis-Sessions +- Dashboard-Layout (Sidebar, Server-Switcher, Dark/Light) +- Settings-Framework mit Sticky-Save-Bar +- Shared Zod-API-Schemas; Guild-Settings- und Module-Toggle-API +- Dashboard-Audit + Access-Rules (Prisma) ## Nächster geplanter Schritt -- Manuelle Phase-5-Tests, dann nach Freigabe Phase 6 (WebUI-Fundament: OAuth, Layout, Settings-Framework, API). +- Phase 6 fertigstellen, dann nach Freigabe Phase 7 (Modul-Seiten + Owner-Panel). diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 73ac951..c09904d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './phase2.js'; export * from './phase3.js'; export * from './phase4.js'; export * from './phase5.js'; +export * from './phase6.js'; export const LocaleSchema = z.enum(['de', 'en']); export type Locale = z.infer; @@ -14,6 +15,7 @@ export type Locale = z.infer; export const GuildSettingsSchema = z.object({ guildId: z.string(), locale: LocaleSchema.default('de'), + timezone: z.string().default('Europe/Berlin'), moderationEnabled: z.boolean().default(true) }); diff --git a/packages/shared/src/phase6.test.ts b/packages/shared/src/phase6.test.ts new file mode 100644 index 0000000..6946d7f --- /dev/null +++ b/packages/shared/src/phase6.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + GuildGeneralSettingsPatchSchema, + ModulesUpdateSchema, + hasManageGuildPermission, + DashboardAccessRulesUpdateSchema +} from './phase6.js'; + +describe('phase6 schemas', () => { + it('accepts general settings patches', () => { + const parsed = GuildGeneralSettingsPatchSchema.parse({ + locale: 'en', + timezone: 'UTC' + }); + expect(parsed.locale).toBe('en'); + expect(parsed.timezone).toBe('UTC'); + }); + + it('rejects empty general settings patches', () => { + expect(() => GuildGeneralSettingsPatchSchema.parse({})).toThrow(); + }); + + it('validates module updates', () => { + const parsed = ModulesUpdateSchema.parse({ + modules: { moderation: true, automod: false } + }); + expect(parsed.modules.moderation).toBe(true); + expect(parsed.modules.automod).toBe(false); + }); + + it('detects manage guild permission', () => { + expect(hasManageGuildPermission('32')).toBe(true); + expect(hasManageGuildPermission('0')).toBe(false); + expect(hasManageGuildPermission(String(32n | 8n))).toBe(true); + }); + + it('validates access rules', () => { + const parsed = DashboardAccessRulesUpdateSchema.parse({ + rules: [{ roleId: '123456789012345678', modules: 'all' }] + }); + expect(parsed.rules).toHaveLength(1); + }); +}); diff --git a/packages/shared/src/phase6.ts b/packages/shared/src/phase6.ts new file mode 100644 index 0000000..09785cc --- /dev/null +++ b/packages/shared/src/phase6.ts @@ -0,0 +1,149 @@ +import { z } from 'zod'; + +const LocaleSchema = z.enum(['de', 'en']); + +export const DashboardModuleIdSchema = z.enum([ + 'moderation', + 'automod', + 'logging', + 'welcome', + 'verification', + 'leveling', + 'economy', + 'fun', + 'giveaways', + 'tickets', + 'selfroles', + 'tags', + 'starboard', + 'suggestions', + 'birthdays', + 'tempvoice', + 'stats', + 'feeds', + 'scheduler', + 'guildbackup' +]); + +export type DashboardModuleId = z.infer; + +export const DashboardModuleGroupSchema = z.enum([ + 'moderation', + 'engagement', + 'utility', + 'community', + 'integrations' +]); + +export type DashboardModuleGroup = z.infer; + +export const DASHBOARD_MODULES = [ + { id: 'moderation', group: 'moderation', href: 'moderation' }, + { id: 'automod', group: 'moderation', href: 'automod' }, + { id: 'logging', group: 'moderation', href: 'logging' }, + { id: 'welcome', group: 'engagement', href: 'welcome' }, + { id: 'verification', group: 'engagement', href: 'verification' }, + { id: 'leveling', group: 'engagement', href: 'leveling' }, + { id: 'economy', group: 'engagement', href: 'economy' }, + { id: 'fun', group: 'engagement', href: 'fun' }, + { id: 'giveaways', group: 'community', href: 'giveaways' }, + { id: 'tickets', group: 'community', href: 'tickets' }, + { id: 'selfroles', group: 'community', href: 'selfroles' }, + { id: 'tags', group: 'utility', href: 'tags' }, + { id: 'starboard', group: 'community', href: 'starboard' }, + { id: 'suggestions', group: 'community', href: 'suggestions' }, + { id: 'birthdays', group: 'community', href: 'birthdays' }, + { id: 'tempvoice', group: 'utility', href: 'tempvoice' }, + { id: 'stats', group: 'integrations', href: 'stats' }, + { id: 'feeds', group: 'integrations', href: 'feeds' }, + { id: 'scheduler', group: 'integrations', href: 'scheduler' }, + { id: 'guildbackup', group: 'integrations', href: 'backup' } +] as const satisfies ReadonlyArray<{ + id: DashboardModuleId; + group: DashboardModuleGroup; + href: string; +}>; + +export const SessionUserSchema = z.object({ + id: z.string().min(1), + username: z.string().min(1), + globalName: z.string().nullable(), + avatar: z.string().nullable(), + locale: LocaleSchema.optional() +}); + +export type SessionUser = z.infer; + +export const DashboardGuildSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + icon: z.string().nullable(), + botPresent: z.boolean(), + permissions: z.string() +}); + +export type DashboardGuild = z.infer; + +export const GuildGeneralSettingsSchema = z.object({ + locale: LocaleSchema, + timezone: z.string().min(1).max(64), + moderationEnabled: z.boolean() +}); + +export type GuildGeneralSettings = z.infer; + +export const GuildGeneralSettingsPatchSchema = GuildGeneralSettingsSchema.partial().refine( + (value) => Object.keys(value).length > 0, + { message: 'At least one field is required' } +); + +export type GuildGeneralSettingsPatch = z.infer; + +export const ModuleStatusSchema = z.object({ + id: DashboardModuleIdSchema, + enabled: z.boolean(), + group: DashboardModuleGroupSchema, + href: z.string() +}); + +export type ModuleStatus = z.infer; + +export const ModulesUpdateSchema = z.object({ + modules: z.record(DashboardModuleIdSchema, z.boolean()).refine((value) => Object.keys(value).length > 0, { + message: 'At least one module toggle is required' + }) +}); + +export type ModulesUpdate = z.infer; + +export const DashboardAccessRuleSchema = z.object({ + id: z.string().optional(), + roleId: z.string().regex(/^\d{17,20}$/), + modules: z.union([z.literal('all'), z.array(DashboardModuleIdSchema).min(1)]) +}); + +export type DashboardAccessRule = z.infer; + +export const DashboardAccessRulesUpdateSchema = z.object({ + rules: z.array(DashboardAccessRuleSchema) +}); + +export type DashboardAccessRulesUpdate = z.infer; + +export const DashboardAuditEntrySchema = z.object({ + id: z.string(), + guildId: z.string().nullable(), + actorUserId: z.string(), + action: z.string(), + path: z.string().nullable(), + createdAt: z.string() +}); + +export type DashboardAuditEntry = z.infer; + +export const DISCORD_PERMISSION_MANAGE_GUILD = 1n << 5n; + +export function hasManageGuildPermission(permissions: string | bigint): boolean { + const value = typeof permissions === 'bigint' ? permissions : BigInt(permissions); + return (value & DISCORD_PERMISSION_MANAGE_GUILD) === DISCORD_PERMISSION_MANAGE_GUILD; +}