From b6b48eafb35e827ee23c790ff490048066cb0273 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:30:57 +0000 Subject: [PATCH 1/8] feat: modularize theme editor --- .../src/components/editors/ThemeEditor.tsx | 210 +++--------------- .../editors/theme/PaletteEditor.tsx | 86 +++++++ .../components/editors/theme/PreviewPane.tsx | 33 +++ .../src/components/editors/theme/constants.ts | 41 ++++ .../src/components/editors/theme/types.ts | 25 +++ 5 files changed, 210 insertions(+), 185 deletions(-) create mode 100644 frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx create mode 100644 frontends/nextjs/src/components/editors/theme/PreviewPane.tsx create mode 100644 frontends/nextjs/src/components/editors/theme/constants.ts create mode 100644 frontends/nextjs/src/components/editors/theme/types.ts diff --git a/frontends/nextjs/src/components/editors/ThemeEditor.tsx b/frontends/nextjs/src/components/editors/ThemeEditor.tsx index 03c56b878..9800bdb50 100644 --- a/frontends/nextjs/src/components/editors/ThemeEditor.tsx +++ b/frontends/nextjs/src/components/editors/ThemeEditor.tsx @@ -1,79 +1,15 @@ -import { useState, useEffect } from 'react' +import { useEffect, useState } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Label } from '@/components/ui' -import { Input } from '@/components/ui' import { Button } from '@/components/ui' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui' import { Switch } from '@/components/ui' import { Palette, Sun, Moon, FloppyDisk, ArrowCounterClockwise } from '@phosphor-icons/react' import { toast } from 'sonner' import { useKV } from '@github/spark/hooks' - -interface ThemeColors { - background: string - foreground: string - card: string - cardForeground: string - primary: string - primaryForeground: string - secondary: string - secondaryForeground: string - muted: string - mutedForeground: string - accent: string - accentForeground: string - destructive: string - destructiveForeground: string - border: string - input: string - ring: string -} - -interface ThemeConfig { - light: ThemeColors - dark: ThemeColors - radius: string -} - -const DEFAULT_LIGHT_THEME: ThemeColors = { - background: 'oklch(0.92 0.03 290)', - foreground: 'oklch(0.25 0.02 260)', - card: 'oklch(1 0 0)', - cardForeground: 'oklch(0.25 0.02 260)', - primary: 'oklch(0.55 0.18 290)', - primaryForeground: 'oklch(0.98 0 0)', - secondary: 'oklch(0.35 0.02 260)', - secondaryForeground: 'oklch(0.90 0.01 260)', - muted: 'oklch(0.95 0.02 290)', - mutedForeground: 'oklch(0.50 0.02 260)', - accent: 'oklch(0.70 0.17 195)', - accentForeground: 'oklch(0.2 0.02 260)', - destructive: 'oklch(0.55 0.22 25)', - destructiveForeground: 'oklch(0.98 0 0)', - border: 'oklch(0.85 0.02 290)', - input: 'oklch(0.85 0.02 290)', - ring: 'oklch(0.70 0.17 195)', -} - -const DEFAULT_DARK_THEME: ThemeColors = { - background: 'oklch(0.145 0 0)', - foreground: 'oklch(0.985 0 0)', - card: 'oklch(0.205 0 0)', - cardForeground: 'oklch(0.985 0 0)', - primary: 'oklch(0.922 0 0)', - primaryForeground: 'oklch(0.205 0 0)', - secondary: 'oklch(0.269 0 0)', - secondaryForeground: 'oklch(0.985 0 0)', - muted: 'oklch(0.269 0 0)', - mutedForeground: 'oklch(0.708 0 0)', - accent: 'oklch(0.269 0 0)', - accentForeground: 'oklch(0.985 0 0)', - destructive: 'oklch(0.704 0.191 22.216)', - destructiveForeground: 'oklch(0.98 0 0)', - border: 'oklch(1 0 0 / 10%)', - input: 'oklch(1 0 0 / 15%)', - ring: 'oklch(0.556 0 0)', -} +import { PaletteEditor } from './theme/PaletteEditor' +import { PreviewPane } from './theme/PreviewPane' +import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from './theme/constants' +import { ThemeColors, ThemeConfig } from './theme/types' export function ThemeEditor() { const [themeConfig, setThemeConfig] = useKV('theme_config', { @@ -81,7 +17,7 @@ export function ThemeEditor() { dark: DEFAULT_DARK_THEME, radius: '0.5rem', }) - + const [isDarkMode, setIsDarkMode] = useKV('dark_mode_enabled', false) const [editingTheme, setEditingTheme] = useState<'light' | 'dark'>('light') const [localColors, setLocalColors] = useState(DEFAULT_LIGHT_THEME) @@ -95,30 +31,19 @@ export function ThemeEditor() { }, [editingTheme, themeConfig]) useEffect(() => { - if (themeConfig) { - applyTheme() - } - }, [themeConfig, isDarkMode]) - - const applyTheme = () => { if (!themeConfig) return - + const root = document.documentElement const colors = isDarkMode ? themeConfig.dark : themeConfig.light - + Object.entries(colors).forEach(([key, value]) => { const cssVarName = key.replace(/([A-Z])/g, '-$1').toLowerCase() root.style.setProperty(`--${cssVarName}`, value) }) - + root.style.setProperty('--radius', themeConfig.radius) - - if (isDarkMode) { - root.classList.add('dark') - } else { - root.classList.remove('dark') - } - } + root.classList.toggle('dark', isDarkMode) + }, [isDarkMode, themeConfig]) const handleColorChange = (colorKey: keyof ThemeColors, value: string) => { setLocalColors((current) => ({ @@ -130,12 +55,14 @@ export function ThemeEditor() { const handleSave = () => { setThemeConfig((current) => { if (!current) return { light: localColors, dark: DEFAULT_DARK_THEME, radius: localRadius } + return { ...current, [editingTheme]: localColors, radius: localRadius, } }) + toast.success('Theme saved successfully') } @@ -151,41 +78,6 @@ export function ThemeEditor() { toast.success(checked ? 'Dark mode enabled' : 'Light mode enabled') } - const colorGroups = [ - { - title: 'Base Colors', - colors: [ - { key: 'background' as const, label: 'Background' }, - { key: 'foreground' as const, label: 'Foreground' }, - { key: 'card' as const, label: 'Card' }, - { key: 'cardForeground' as const, label: 'Card Foreground' }, - ], - }, - { - title: 'Action Colors', - colors: [ - { key: 'primary' as const, label: 'Primary' }, - { key: 'primaryForeground' as const, label: 'Primary Foreground' }, - { key: 'secondary' as const, label: 'Secondary' }, - { key: 'secondaryForeground' as const, label: 'Secondary Foreground' }, - { key: 'accent' as const, label: 'Accent' }, - { key: 'accentForeground' as const, label: 'Accent Foreground' }, - { key: 'destructive' as const, label: 'Destructive' }, - { key: 'destructiveForeground' as const, label: 'Destructive Foreground' }, - ], - }, - { - title: 'Supporting Colors', - colors: [ - { key: 'muted' as const, label: 'Muted' }, - { key: 'mutedForeground' as const, label: 'Muted Foreground' }, - { key: 'border' as const, label: 'Border' }, - { key: 'input' as const, label: 'Input' }, - { key: 'ring' as const, label: 'Ring' }, - ], - }, - ] - return (
@@ -196,9 +88,7 @@ export function ThemeEditor() { Theme Editor - - Customize the application theme colors and appearance - + Customize the application theme colors and appearance
@@ -207,52 +97,21 @@ export function ThemeEditor() {
- - setEditingTheme(v as 'light' | 'dark')}> + + + setEditingTheme(value as 'light' | 'dark')}> Light Theme Dark Theme - - -
-
- - setLocalRadius(e.target.value)} - placeholder="e.g., 0.5rem" - className="mt-1.5" - /> -
-
- {colorGroups.map((group) => ( -
-

{group.title}

-
- {group.colors.map(({ key, label }) => ( -
- -
-
- handleColorChange(key, e.target.value)} - placeholder="oklch(...)" - className="font-mono text-sm" - /> -
-
- ))} -
-
- ))} + +
- - - -
- - - Card Example - This is a card description - - -

Card content with muted text

-
-
-
- +
diff --git a/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx b/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx new file mode 100644 index 000000000..2950b135b --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx @@ -0,0 +1,86 @@ +import { Input, Label } from '@/components/ui' +import { ThemeColors } from './types' + +const colorGroups = [ + { + title: 'Base Colors', + colors: [ + { key: 'background' as const, label: 'Background' }, + { key: 'foreground' as const, label: 'Foreground' }, + { key: 'card' as const, label: 'Card' }, + { key: 'cardForeground' as const, label: 'Card Foreground' }, + ], + }, + { + title: 'Action Colors', + colors: [ + { key: 'primary' as const, label: 'Primary' }, + { key: 'primaryForeground' as const, label: 'Primary Foreground' }, + { key: 'secondary' as const, label: 'Secondary' }, + { key: 'secondaryForeground' as const, label: 'Secondary Foreground' }, + { key: 'accent' as const, label: 'Accent' }, + { key: 'accentForeground' as const, label: 'Accent Foreground' }, + { key: 'destructive' as const, label: 'Destructive' }, + { key: 'destructiveForeground' as const, label: 'Destructive Foreground' }, + ], + }, + { + title: 'Supporting Colors', + colors: [ + { key: 'muted' as const, label: 'Muted' }, + { key: 'mutedForeground' as const, label: 'Muted Foreground' }, + { key: 'border' as const, label: 'Border' }, + { key: 'input' as const, label: 'Input' }, + { key: 'ring' as const, label: 'Ring' }, + ], + }, +] + +interface PaletteEditorProps { + colors: ThemeColors + radius: string + onColorChange: (colorKey: keyof ThemeColors, value: string) => void + onRadiusChange: (value: string) => void +} + +export function PaletteEditor({ colors, radius, onColorChange, onRadiusChange }: PaletteEditorProps) { + return ( +
+
+
+ + onRadiusChange(e.target.value)} + placeholder="e.g., 0.5rem" + className="mt-1.5" + /> +
+
+ + {colorGroups.map((group) => ( +
+

{group.title}

+
+ {group.colors.map(({ key, label }) => ( +
+ +
+
+ onColorChange(key, e.target.value)} + placeholder="oklch(...)" + className="font-mono text-sm" + /> +
+
+ ))} +
+
+ ))} +
+ ) +} diff --git a/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx b/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx new file mode 100644 index 000000000..795ccf45d --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx @@ -0,0 +1,33 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' +import { Button } from '@/components/ui' + +export function PreviewPane() { + return ( +
+

Theme Preview

+
+
+ + + + +
+ + + Card Example + This is a card description + + +

Card content with muted text

+
+
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/theme/constants.ts b/frontends/nextjs/src/components/editors/theme/constants.ts new file mode 100644 index 000000000..7e7b8f8b2 --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/constants.ts @@ -0,0 +1,41 @@ +import { ThemeColors } from './types' + +export const DEFAULT_LIGHT_THEME: ThemeColors = { + background: 'oklch(0.92 0.03 290)', + foreground: 'oklch(0.25 0.02 260)', + card: 'oklch(1 0 0)', + cardForeground: 'oklch(0.25 0.02 260)', + primary: 'oklch(0.55 0.18 290)', + primaryForeground: 'oklch(0.98 0 0)', + secondary: 'oklch(0.35 0.02 260)', + secondaryForeground: 'oklch(0.90 0.01 260)', + muted: 'oklch(0.95 0.02 290)', + mutedForeground: 'oklch(0.50 0.02 260)', + accent: 'oklch(0.70 0.17 195)', + accentForeground: 'oklch(0.2 0.02 260)', + destructive: 'oklch(0.55 0.22 25)', + destructiveForeground: 'oklch(0.98 0 0)', + border: 'oklch(0.85 0.02 290)', + input: 'oklch(0.85 0.02 290)', + ring: 'oklch(0.70 0.17 195)', +} + +export const DEFAULT_DARK_THEME: ThemeColors = { + background: 'oklch(0.145 0 0)', + foreground: 'oklch(0.985 0 0)', + card: 'oklch(0.205 0 0)', + cardForeground: 'oklch(0.985 0 0)', + primary: 'oklch(0.922 0 0)', + primaryForeground: 'oklch(0.205 0 0)', + secondary: 'oklch(0.269 0 0)', + secondaryForeground: 'oklch(0.985 0 0)', + muted: 'oklch(0.269 0 0)', + mutedForeground: 'oklch(0.708 0 0)', + accent: 'oklch(0.269 0 0)', + accentForeground: 'oklch(0.985 0 0)', + destructive: 'oklch(0.704 0.191 22.216)', + destructiveForeground: 'oklch(0.98 0 0)', + border: 'oklch(1 0 0 / 10%)', + input: 'oklch(1 0 0 / 15%)', + ring: 'oklch(0.556 0 0)', +} diff --git a/frontends/nextjs/src/components/editors/theme/types.ts b/frontends/nextjs/src/components/editors/theme/types.ts new file mode 100644 index 000000000..8440c06d8 --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/types.ts @@ -0,0 +1,25 @@ +export interface ThemeColors { + background: string + foreground: string + card: string + cardForeground: string + primary: string + primaryForeground: string + secondary: string + secondaryForeground: string + muted: string + mutedForeground: string + accent: string + accentForeground: string + destructive: string + destructiveForeground: string + border: string + input: string + ring: string +} + +export interface ThemeConfig { + light: ThemeColors + dark: ThemeColors + radius: string +} From 07efe7609a58e55349da4807cd4c85540e60cc12 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:31:22 +0000 Subject: [PATCH 2/8] refactor: extract json editor ui components --- .../src/components/editors/JsonEditor.tsx | 77 +++++++++---------- .../components/editors/json/SchemaSection.tsx | 26 +++++++ .../src/components/editors/json/Toolbar.tsx | 31 ++++++++ 3 files changed, 93 insertions(+), 41 deletions(-) create mode 100644 frontends/nextjs/src/components/editors/json/SchemaSection.tsx create mode 100644 frontends/nextjs/src/components/editors/json/Toolbar.tsx diff --git a/frontends/nextjs/src/components/editors/JsonEditor.tsx b/frontends/nextjs/src/components/editors/JsonEditor.tsx index 7b6beb59a..f6ebcf593 100644 --- a/frontends/nextjs/src/components/editors/JsonEditor.tsx +++ b/frontends/nextjs/src/components/editors/JsonEditor.tsx @@ -1,12 +1,13 @@ -import { useState, useEffect } from 'react' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui' -import { Button } from '@/components/ui' -import { Alert, AlertDescription } from '@/components/ui' -import { FloppyDisk, X, Warning, ShieldCheck } from '@phosphor-icons/react' +import { useEffect, useState } from 'react' +import { Alert, AlertDescription, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui' +import { Warning } from '@phosphor-icons/react' import Editor from '@monaco-editor/react' +import { toast } from 'sonner' + +import { SchemaSection } from './json/SchemaSection' +import { Toolbar } from './json/Toolbar' import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner' import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog' -import { toast } from 'sonner' interface JsonEditorProps { open: boolean @@ -32,10 +33,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json } }, [open, value]) + const parseJson = () => JSON.parse(jsonText) + const handleSave = () => { try { - const parsed = JSON.parse(jsonText) - + const parsed = parseJson() + const scanResult = securityScanner.scanJSON(jsonText) setSecurityScanResult(scanResult) @@ -66,8 +69,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json const handleForceSave = () => { try { - const parsed = JSON.parse(jsonText) - onSave(parsed) + onSave(parseJson()) setError(null) setPendingSave(false) setShowSecurityDialog(false) @@ -81,7 +83,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json const scanResult = securityScanner.scanJSON(jsonText) setSecurityScanResult(scanResult) setShowSecurityDialog(true) - + if (scanResult.safe) { toast.success('No security issues detected') } else { @@ -91,8 +93,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json const handleFormat = () => { try { - const parsed = JSON.parse(jsonText) - setJsonText(JSON.stringify(parsed, null, 2)) + setJsonText(JSON.stringify(parseJson(), null, 2)) setError(null) } catch (err) { setError(err instanceof Error ? err.message : 'Invalid JSON - cannot format') @@ -106,7 +107,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json {title} - +
{error && ( @@ -115,16 +116,21 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json )} - {securityScanResult && securityScanResult.severity !== 'safe' && securityScanResult.severity !== 'low' && !showSecurityDialog && ( - - - - {securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'} detected. - Click Security Scan to review. - - - )} - + {securityScanResult && + securityScanResult.severity !== 'safe' && + securityScanResult.severity !== 'low' && + !showSecurityDialog && ( + + + + {securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'} +  detected. Click Security Scan to review. + + + )} + + +
- - - - - - + diff --git a/frontends/nextjs/src/components/editors/json/SchemaSection.tsx b/frontends/nextjs/src/components/editors/json/SchemaSection.tsx new file mode 100644 index 000000000..b77ca8486 --- /dev/null +++ b/frontends/nextjs/src/components/editors/json/SchemaSection.tsx @@ -0,0 +1,26 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' + +interface SchemaSectionProps { + schema?: unknown +} + +export function SchemaSection({ schema }: SchemaSectionProps) { + if (!schema) return null + + const formattedSchema = + typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2) + + return ( + + + Schema + Reference for the expected JSON structure + + +
+          {formattedSchema}
+        
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/json/Toolbar.tsx b/frontends/nextjs/src/components/editors/json/Toolbar.tsx new file mode 100644 index 000000000..a642ce6fc --- /dev/null +++ b/frontends/nextjs/src/components/editors/json/Toolbar.tsx @@ -0,0 +1,31 @@ +import { Button, DialogFooter } from '@/components/ui' +import { FloppyDisk, ShieldCheck, X } from '@phosphor-icons/react' + +interface ToolbarProps { + onScan: () => void + onFormat: () => void + onCancel: () => void + onSave: () => void +} + +export function Toolbar({ onScan, onFormat, onCancel, onSave }: ToolbarProps) { + return ( + + + + + + + ) +} From 149ee90339b9b6321d6a1d899d8797a44dc431d8 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:32:01 +0000 Subject: [PATCH 3/8] chore: add foundation type modules --- .../src/core/foundation/types/entities.ts | 19 +++++++++++++++++++ .../src/core/foundation/types/events.ts | 13 +++++++++++++ .../src/core/foundation/types/index.ts | 3 +++ .../src/core/foundation/types/operations.ts | 19 +++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 dbal/development/src/core/foundation/types/entities.ts create mode 100644 dbal/development/src/core/foundation/types/events.ts create mode 100644 dbal/development/src/core/foundation/types/operations.ts diff --git a/dbal/development/src/core/foundation/types/entities.ts b/dbal/development/src/core/foundation/types/entities.ts new file mode 100644 index 000000000..dcd20b271 --- /dev/null +++ b/dbal/development/src/core/foundation/types/entities.ts @@ -0,0 +1,19 @@ +export type EntityId = string + +export interface BaseEntity { + id: EntityId + createdAt: Date + updatedAt: Date +} + +export interface SoftDeletableEntity extends BaseEntity { + deletedAt?: Date +} + +export interface TenantScopedEntity extends BaseEntity { + tenantId: string +} + +export interface EntityMetadata { + metadata?: Record +} diff --git a/dbal/development/src/core/foundation/types/events.ts b/dbal/development/src/core/foundation/types/events.ts new file mode 100644 index 000000000..5679fb156 --- /dev/null +++ b/dbal/development/src/core/foundation/types/events.ts @@ -0,0 +1,13 @@ +import type { OperationContext } from './operations' + +export interface DomainEvent> { + id: string + name: string + occurredAt: Date + payload: TPayload + context?: OperationContext +} + +export interface EventHandler> { + (event: DomainEvent): void | Promise +} diff --git a/dbal/development/src/core/foundation/types/index.ts b/dbal/development/src/core/foundation/types/index.ts index 4c2264d64..293944c9a 100644 --- a/dbal/development/src/core/foundation/types/index.ts +++ b/dbal/development/src/core/foundation/types/index.ts @@ -4,3 +4,6 @@ export * from './content' export * from './automation' export * from './packages' export * from './shared' +export * from './entities' +export * from './operations' +export * from './events' diff --git a/dbal/development/src/core/foundation/types/operations.ts b/dbal/development/src/core/foundation/types/operations.ts new file mode 100644 index 000000000..c411a9ea9 --- /dev/null +++ b/dbal/development/src/core/foundation/types/operations.ts @@ -0,0 +1,19 @@ +export interface OperationContext { + tenantId?: string + userId?: string + correlationId?: string + traceId?: string + metadata?: Record +} + +export interface OperationOptions { + timeoutMs?: number + retryCount?: number + dryRun?: boolean +} + +export interface OperationAuditTrail { + performedAt: Date + performedBy?: string + context?: OperationContext +} From a320a8535368842cb0ed606047babcc78c35d916 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:33:06 +0000 Subject: [PATCH 4/8] refactor: split page routes manager components --- .../components/managers/PageRoutesManager.tsx | 233 ++++-------------- .../managers/page-routes/Preview.tsx | 37 +++ .../managers/page-routes/RouteEditor.tsx | 107 ++++++++ .../managers/page-routes/RoutesTable.tsx | 98 ++++++++ 4 files changed, 288 insertions(+), 187 deletions(-) create mode 100644 frontends/nextjs/src/components/managers/page-routes/Preview.tsx create mode 100644 frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx create mode 100644 frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx diff --git a/frontends/nextjs/src/components/managers/PageRoutesManager.tsx b/frontends/nextjs/src/components/managers/PageRoutesManager.tsx index e6ed6411f..e11958f24 100644 --- a/frontends/nextjs/src/components/managers/PageRoutesManager.tsx +++ b/frontends/nextjs/src/components/managers/PageRoutesManager.tsx @@ -1,29 +1,39 @@ -import { useState, useEffect } from 'react' -import { Button } from '@/components/ui' -import { Input } from '@/components/ui' -import { Label } from '@/components/ui' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui' -import { Badge } from '@/components/ui' -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui' -import { Plus, Pencil, Trash, Eye, LockKey } from '@phosphor-icons/react' +import { useEffect, useState } from 'react' +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui' +import { Plus } from '@phosphor-icons/react' import { Database } from '@/lib/database' +import type { PageConfig } from '@/lib/level-types' import { toast } from 'sonner' -import type { PageConfig, UserRole, AppLevel } from '@/lib/level-types' -import { Switch } from '@/components/ui' +import { RoutesTable } from './page-routes/RoutesTable' +import { Preview } from './page-routes/Preview' +import { RouteEditor, RouteFormData } from './page-routes/RouteEditor' + +const defaultFormData: RouteFormData = { + path: '/', + title: '', + level: 1, + requiresAuth: false, + componentTree: [], +} export function PageRoutesManager() { const [pages, setPages] = useState([]) const [isDialogOpen, setIsDialogOpen] = useState(false) const [editingPage, setEditingPage] = useState(null) - const [formData, setFormData] = useState>({ - path: '/', - title: '', - level: 1, - requiresAuth: false, - componentTree: [], - }) + const [formData, setFormData] = useState({ ...defaultFormData }) useEffect(() => { loadPages() @@ -40,13 +50,7 @@ export function PageRoutesManager() { setFormData(page) } else { setEditingPage(null) - setFormData({ - path: '/', - title: '', - level: 1, - requiresAuth: false, - componentTree: [], - }) + setFormData({ ...defaultFormData }) } setIsDialogOpen(true) } @@ -54,13 +58,7 @@ export function PageRoutesManager() { const handleCloseDialog = () => { setIsDialogOpen(false) setEditingPage(null) - setFormData({ - path: '/', - title: '', - level: 1, - requiresAuth: false, - componentTree: [], - }) + setFormData({ ...defaultFormData }) } const handleSavePage = async () => { @@ -98,18 +96,6 @@ export function PageRoutesManager() { } } - const getLevelBadgeColor = (level: AppLevel) => { - switch (level) { - case 1: return 'bg-blue-500' - case 2: return 'bg-green-500' - case 3: return 'bg-orange-500' - case 4: return 'bg-sky-500' - case 5: return 'bg-purple-500' - case 6: return 'bg-rose-500' - default: return 'bg-gray-500' - } - } - return (
@@ -124,94 +110,23 @@ export function PageRoutesManager() { New Page Route - + {editingPage ? 'Edit Page Route' : 'Create New Page Route'} Configure the route path, access level, and authentication requirements - -
-
-
- - setFormData({ ...formData, path: e.target.value })} - /> -
-
- - setFormData({ ...formData, title: e.target.value })} - /> -
-
- -
-
- - -
- -
- - -
-
- -
- setFormData({ ...formData, requiresAuth: checked })} - /> - -
+
+ +
- - - - -
@@ -222,67 +137,11 @@ export function PageRoutesManager() { All page routes in your application - {pages.length === 0 ? ( -
-

No pages configured yet. Create your first page route!

-
- ) : ( - - - - Path - Title - Level - Auth - Required Role - Actions - - - - {pages.map((page) => ( - - {page.path} - {page.title} - - - Level {page.level} - - - - {page.requiresAuth ? ( - - ) : ( - - )} - - - - {page.requiredRole || 'public'} - - - -
- - -
-
-
- ))} -
-
- )} +
diff --git a/frontends/nextjs/src/components/managers/page-routes/Preview.tsx b/frontends/nextjs/src/components/managers/page-routes/Preview.tsx new file mode 100644 index 000000000..2bfd2e207 --- /dev/null +++ b/frontends/nextjs/src/components/managers/page-routes/Preview.tsx @@ -0,0 +1,37 @@ +import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' +import { Eye, LockKey } from '@phosphor-icons/react' +import type { PageConfig } from '@/lib/level-types' + +interface PreviewProps { + formData: Partial +} + +export function Preview({ formData }: PreviewProps) { + return ( + + + Route Preview + Quick glance at the route details + + +
+

Path

+

{formData.path || '/your-path'}

+
+
+

Title

+

{formData.title || 'Untitled Page'}

+
+
+ Level {formData.level || 1} + {formData.requiredRole || 'public'} + {formData.requiresAuth ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx b/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx new file mode 100644 index 000000000..e772c97ff --- /dev/null +++ b/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx @@ -0,0 +1,107 @@ +import { + Button, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from '@/components/ui' +import type { PageConfig, UserRole, AppLevel } from '@/lib/level-types' + +export type RouteFormData = Partial + +interface RouteEditorProps { + formData: RouteFormData + onChange: (value: RouteFormData) => void + onSave: () => void + onCancel: () => void + isEdit: boolean +} + +export function RouteEditor({ formData, onChange, onSave, onCancel, isEdit }: RouteEditorProps) { + return ( +
+
+
+ + onChange({ ...formData, path: e.target.value })} + /> +
+
+ + onChange({ ...formData, title: e.target.value })} + /> +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ onChange({ ...formData, requiresAuth: checked })} + /> + +
+ +
+ + +
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx b/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx new file mode 100644 index 000000000..be7f98038 --- /dev/null +++ b/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx @@ -0,0 +1,98 @@ +import { + Badge, + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui' +import { Eye, LockKey, Pencil, Trash } from '@phosphor-icons/react' +import type { PageConfig, AppLevel } from '@/lib/level-types' + +interface RoutesTableProps { + pages: PageConfig[] + onEdit: (page: PageConfig) => void + onDelete: (pageId: string) => void +} + +const getLevelBadgeColor = (level: AppLevel) => { + switch (level) { + case 1: return 'bg-blue-500' + case 2: return 'bg-green-500' + case 3: return 'bg-orange-500' + case 4: return 'bg-sky-500' + case 5: return 'bg-purple-500' + case 6: return 'bg-rose-500' + default: return 'bg-gray-500' + } +} + +export function RoutesTable({ pages, onEdit, onDelete }: RoutesTableProps) { + if (pages.length === 0) { + return ( +
+

No pages configured yet. Create your first page route!

+
+ ) + } + + return ( + + + + Path + Title + Level + Auth + Required Role + Actions + + + + {pages.map((page) => ( + + {page.path} + {page.title} + + + Level {page.level} + + + + {page.requiresAuth ? ( + + ) : ( + + )} + + + + {page.requiredRole || 'public'} + + + +
+ + +
+
+
+ ))} +
+
+ ) +} From d9a8e75fbfcc33da118344a4263acc9175231ac3 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:34:09 +0000 Subject: [PATCH 5/8] refactor: extract dropdown manager components --- .../managers/DropdownConfigManager.tsx | 201 +++--------------- .../managers/dropdown/DropdownConfigForm.tsx | 182 ++++++++++++++++ .../managers/dropdown/PreviewPane.tsx | 44 ++++ 3 files changed, 260 insertions(+), 167 deletions(-) create mode 100644 frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx create mode 100644 frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx diff --git a/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx b/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx index 254d0132e..dbd2edc00 100644 --- a/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx +++ b/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx @@ -1,26 +1,16 @@ -import { useState, useEffect } from 'react' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui' -import { Button } from '@/components/ui' -import { Input } from '@/components/ui' -import { Label } from '@/components/ui' -import { ScrollArea } from '@/components/ui' -import { Card } from '@/components/ui' -import { Badge } from '@/components/ui' -import { Separator } from '@/components/ui' +import { useEffect, useState } from 'react' +import { Button, Card } from '@/components/ui' import { Database } from '@/lib/database' -import { Plus, X, FloppyDisk, Trash, Pencil } from '@phosphor-icons/react' +import { Plus } from '@phosphor-icons/react' import { toast } from 'sonner' import type { DropdownConfig } from '@/lib/database' +import { DropdownConfigForm } from './dropdown/DropdownConfigForm' +import { PreviewPane } from './dropdown/PreviewPane' export function DropdownConfigManager() { const [dropdowns, setDropdowns] = useState([]) const [isEditing, setIsEditing] = useState(false) const [editingDropdown, setEditingDropdown] = useState(null) - const [dropdownName, setDropdownName] = useState('') - const [dropdownLabel, setDropdownLabel] = useState('') - const [options, setOptions] = useState>([]) - const [newOptionValue, setNewOptionValue] = useState('') - const [newOptionLabel, setNewOptionLabel] = useState('') useEffect(() => { loadDropdowns() @@ -31,63 +21,34 @@ export function DropdownConfigManager() { setDropdowns(configs) } - const startEdit = (dropdown?: DropdownConfig) => { - if (dropdown) { - setEditingDropdown(dropdown) - setDropdownName(dropdown.name) - setDropdownLabel(dropdown.label) - setOptions(dropdown.options) - } else { - setEditingDropdown(null) - setDropdownName('') - setDropdownLabel('') - setOptions([]) - } + const openEditor = (dropdown?: DropdownConfig) => { + setEditingDropdown(dropdown ?? null) setIsEditing(true) } - const addOption = () => { - if (newOptionValue && newOptionLabel) { - setOptions(current => [...current, { value: newOptionValue, label: newOptionLabel }]) - setNewOptionValue('') - setNewOptionLabel('') - } - } - - const removeOption = (index: number) => { - setOptions(current => current.filter((_, i) => i !== index)) - } - - const handleSave = async () => { - if (!dropdownName || !dropdownLabel || options.length === 0) { - toast.error('Please fill all fields and add at least one option') - return - } - - const newDropdown: DropdownConfig = { - id: editingDropdown?.id || `dropdown_${Date.now()}`, - name: dropdownName, - label: dropdownLabel, - options, - } - - if (editingDropdown) { - await Database.updateDropdownConfig(newDropdown.id, newDropdown) + const handleSave = async (config: DropdownConfig, isEdit: boolean) => { + if (isEdit) { + await Database.updateDropdownConfig(config.id, config) toast.success('Dropdown updated successfully') } else { - await Database.addDropdownConfig(newDropdown) + await Database.addDropdownConfig(config) toast.success('Dropdown created successfully') } setIsEditing(false) - loadDropdowns() + await loadDropdowns() } const handleDelete = async (id: string) => { - if (confirm('Are you sure you want to delete this dropdown configuration?')) { - await Database.deleteDropdownConfig(id) - toast.success('Dropdown deleted') - loadDropdowns() + await Database.deleteDropdownConfig(id) + toast.success('Dropdown deleted') + await loadDropdowns() + } + + const handleDialogChange = (open: boolean) => { + setIsEditing(open) + if (!open) { + setEditingDropdown(null) } } @@ -98,7 +59,7 @@ export function DropdownConfigManager() {

Dropdown Configurations

Manage dynamic dropdown options for properties

- @@ -106,30 +67,12 @@ export function DropdownConfigManager() {
{dropdowns.map(dropdown => ( - -
-
-

{dropdown.label}

-

{dropdown.name}

-
-
- - -
-
- -
- {dropdown.options.map((opt, i) => ( - - {opt.label} - - ))} -
-
+ ))}
@@ -139,88 +82,12 @@ export function DropdownConfigManager() { )} - - - - {editingDropdown ? 'Edit' : 'Create'} Dropdown Configuration - - -
-
- - setDropdownName(e.target.value)} - /> -

Unique identifier for this dropdown

-
- -
- - setDropdownLabel(e.target.value)} - /> -
- - - -
- -
- setNewOptionValue(e.target.value)} - /> - setNewOptionLabel(e.target.value)} - /> - -
-
- - {options.length > 0 && ( - -
- {options.map((opt, i) => ( -
-
- {opt.value} - - {opt.label} -
- -
- ))} -
-
- )} -
- - - - - -
-
+
) } diff --git a/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx b/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx new file mode 100644 index 000000000..754d4cf73 --- /dev/null +++ b/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useState } from 'react' +import { Badge, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, ScrollArea, Separator } from '@/components/ui' +import { FloppyDisk, Plus, X } from '@phosphor-icons/react' +import { toast } from 'sonner' +import type { DropdownConfig } from '@/lib/database' + +interface DropdownConfigFormProps { + open: boolean + editingDropdown: DropdownConfig | null + onOpenChange: (open: boolean) => void + onSave: (config: DropdownConfig, isEdit: boolean) => Promise | void +} + +const getDefaultOptions = (dropdown?: DropdownConfig | null) => dropdown?.options ?? [] + +const buildDropdownConfig = ( + dropdown: DropdownConfig | null, + name: string, + label: string, + options: Array<{ value: string; label: string }> +): DropdownConfig => ({ + id: dropdown?.id ?? `dropdown_${Date.now()}`, + name: name.trim(), + label: label.trim(), + options, +}) + +export function DropdownConfigForm({ open, editingDropdown, onOpenChange, onSave }: DropdownConfigFormProps) { + const [dropdownName, setDropdownName] = useState('') + const [dropdownLabel, setDropdownLabel] = useState('') + const [options, setOptions] = useState>([]) + const [newOptionValue, setNewOptionValue] = useState('') + const [newOptionLabel, setNewOptionLabel] = useState('') + + const isEditMode = useMemo(() => Boolean(editingDropdown), [editingDropdown]) + + useEffect(() => { + if (open) { + setDropdownName(editingDropdown?.name ?? '') + setDropdownLabel(editingDropdown?.label ?? '') + setOptions(getDefaultOptions(editingDropdown)) + } else { + setDropdownName('') + setDropdownLabel('') + setOptions([]) + setNewOptionValue('') + setNewOptionLabel('') + } + }, [open, editingDropdown]) + + const addOption = () => { + if (!newOptionValue.trim() || !newOptionLabel.trim()) { + toast.error('Please provide both a value and label for the option') + return + } + + const duplicate = options.some( + (opt) => opt.value.toLowerCase() === newOptionValue.trim().toLowerCase() + ) + + if (duplicate) { + toast.error('An option with this value already exists') + return + } + + setOptions((current) => [ + ...current, + { value: newOptionValue.trim(), label: newOptionLabel.trim() }, + ]) + setNewOptionValue('') + setNewOptionLabel('') + } + + const removeOption = (index: number) => { + setOptions((current) => current.filter((_, i) => i !== index)) + } + + const handleSave = async () => { + if (!dropdownName.trim() || !dropdownLabel.trim() || options.length === 0) { + toast.error('Please fill all fields and add at least one option') + return + } + + const config = buildDropdownConfig(editingDropdown, dropdownName, dropdownLabel, options) + await onSave(config, isEditMode) + onOpenChange(false) + } + + return ( + + + + {isEditMode ? 'Edit' : 'Create'} Dropdown Configuration + + +
+
+ + setDropdownName(e.target.value)} + /> +

Unique identifier for this dropdown

+
+ +
+ + setDropdownLabel(e.target.value)} + /> +
+ + + +
+ +
+ setNewOptionValue(e.target.value)} + /> + setNewOptionLabel(e.target.value)} + /> + +
+
+ + {options.length > 0 && ( + +
+ {options.map((opt, i) => ( +
+
+ {opt.value} + + {opt.label} +
+ +
+ ))} +
+
+ )} +
+ + {options.length === 0 && ( +
+ Tip + Add at least one option to save this dropdown configuration. +
+ )} + + + + + +
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx b/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx new file mode 100644 index 000000000..bbc144249 --- /dev/null +++ b/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx @@ -0,0 +1,44 @@ +import { Badge, Button, Card, Separator } from '@/components/ui' +import { Pencil, Trash } from '@phosphor-icons/react' +import type { DropdownConfig } from '@/lib/database' + +interface PreviewPaneProps { + dropdown: DropdownConfig + onEdit: (dropdown: DropdownConfig) => void + onDelete: (id: string) => void +} + +export function PreviewPane({ dropdown, onEdit, onDelete }: PreviewPaneProps) { + const handleDelete = () => { + if (confirm('Are you sure you want to delete this dropdown configuration?')) { + onDelete(dropdown.id) + } + } + + return ( + +
+
+

{dropdown.label}

+

{dropdown.name}

+
+
+ + +
+
+ +
+ {dropdown.options.map((opt, i) => ( + + {opt.label} + + ))} +
+
+ ) +} From fcd73228614d269baed33672fe609711fc3ee49b Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:34:46 +0000 Subject: [PATCH 6/8] refactor: modularize moderator panel components --- .../level/panels/ModeratorPanel.tsx | 142 +++--------------- .../level/panels/ModeratorPanel/Actions.tsx | 56 +++++++ .../level/panels/ModeratorPanel/Header.tsx | 12 ++ .../level/panels/ModeratorPanel/LogList.tsx | 83 ++++++++++ 4 files changed, 168 insertions(+), 125 deletions(-) create mode 100644 frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx create mode 100644 frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx create mode 100644 frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx index 514ccdb25..f811601a0 100644 --- a/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx +++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx @@ -1,22 +1,13 @@ "use client" import { useEffect, useMemo, useState } from 'react' -import { Button } from '@/components/ui' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Badge } from '@/components/ui' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui' -import { Stack, Typography } from '@/components/ui' import { toast } from 'sonner' +import { AppHeader } from '@/components/shared/AppHeader' import { Database } from '@/lib/database' import type { Comment, User } from '@/lib/level-types' -import { AppHeader } from '@/components/shared/AppHeader' +import { ModeratorActions } from './ModeratorPanel/Actions' +import { ModeratorHeader } from './ModeratorPanel/Header' +import { ModeratorLogList } from './ModeratorPanel/LogList' const FLAGGED_TERMS = ['spam', 'error', 'abuse', 'illegal', 'urgent', 'offensive'] @@ -70,8 +61,6 @@ export function ModeratorPanel({ user, onLogout, onNavigate }: ModeratorPanelPro toast.success('Flag resolved and archived from the queue') } - const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1) - return (
-
- Moderation queue - - Keep the community healthy by resolving flags, reviewing reports, and guiding the tone. - -
- -
- - - Flagged content - Automated signal based on keywords - - - {flaggedComments.length} - - Pending items in the moderation queue - - - - - - - Resolved this session - - - {resolvedIds.length} - - Items you flagged as handled - - - - - - - Community signals - - - - {FLAGGED_TERMS.map((term) => ( - {highlightLabel(term)} - ))} - - - Track the keywords that pulled items into the queue - - - -
- - - -
-
- Flagged comments - A curated view of the comments that triggered a signal -
- -
-
- - {isLoading ? ( - Loading flagged comments… - ) : flaggedComments.length === 0 ? ( - - No flagged comments at the moment. Enjoy the calm. - - ) : ( - - - - User - Comment - Matched terms - Actions - - - - {flaggedComments.map((comment) => { - const matches = FLAGGED_TERMS.filter((term) => - comment.content.toLowerCase().includes(term) - ) - return ( - - {comment.userId} - {comment.content} - - - {matches.map((match) => ( - - {match} - - ))} - - - - - - - ) - })} - -
- )} -
-
+ + +
) diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx new file mode 100644 index 000000000..8bfa78c49 --- /dev/null +++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx @@ -0,0 +1,56 @@ +import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack, Typography } from '@/components/ui' + +interface ModeratorActionsProps { + flaggedCount: number + resolvedCount: number + flaggedTerms: string[] +} + +export function ModeratorActions({ flaggedCount, resolvedCount, flaggedTerms }: ModeratorActionsProps) { + const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1) + + return ( +
+ + + Flagged content + Automated signal based on keywords + + + {flaggedCount} + + Pending items in the moderation queue + + + + + + + Resolved this session + + + {resolvedCount} + + Items you flagged as handled + + + + + + + Community signals + + + + {flaggedTerms.map((term) => ( + {highlightLabel(term)} + ))} + + + Track the keywords that pulled items into the queue + + + +
+ ) +} diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx new file mode 100644 index 000000000..cb86c4a0a --- /dev/null +++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx @@ -0,0 +1,12 @@ +import { Typography } from '@/components/ui' + +export function ModeratorHeader() { + return ( +
+ Moderation queue + + Keep the community healthy by resolving flags, reviewing reports, and guiding the tone. + +
+ ) +} diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx new file mode 100644 index 000000000..b133c6b0b --- /dev/null +++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx @@ -0,0 +1,83 @@ +import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack } from '@/components/ui' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@/components/ui' +import type { Comment } from '@/lib/level-types' + +interface ModeratorLogListProps { + flaggedComments: Comment[] + flaggedTerms: string[] + isLoading: boolean + onNavigate: (level: number) => void + onResolve: (commentId: string) => void +} + +export function ModeratorLogList({ + flaggedComments, + flaggedTerms, + isLoading, + onNavigate, + onResolve, +}: ModeratorLogListProps) { + return ( + + +
+
+ Flagged comments + A curated view of the comments that triggered a signal +
+ +
+
+ + {isLoading ? ( + Loading flagged comments… + ) : flaggedComments.length === 0 ? ( + + No flagged comments at the moment. Enjoy the calm. + + ) : ( + + + + User + Comment + Matched terms + Actions + + + + {flaggedComments.map((comment) => { + const matches = flaggedTerms.filter((term) => + comment.content.toLowerCase().includes(term) + ) + + return ( + + {comment.userId} + {comment.content} + + + {matches.map((match) => ( + + {match} + + ))} + + + + + + + ) + })} + +
+ )} +
+
+ ) +} From 756c48fc8357b2626916c1e5273edcc3a4db83f0 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:35:35 +0000 Subject: [PATCH 7/8] refactor: modularize cpp build assistant --- dbal/shared/tools/cpp-build-assistant.ts | 9 +- dbal/shared/tools/cpp-build-assistant/cli.ts | 125 ++++++++++++++++++ .../tools/cpp-build-assistant/config.ts | 20 +++ .../shared/tools/cpp-build-assistant/index.ts | 112 +++------------- .../tools/cpp-build-assistant/runner.ts | 10 ++ 5 files changed, 183 insertions(+), 93 deletions(-) create mode 100644 dbal/shared/tools/cpp-build-assistant/cli.ts create mode 100644 dbal/shared/tools/cpp-build-assistant/config.ts create mode 100644 dbal/shared/tools/cpp-build-assistant/runner.ts diff --git a/dbal/shared/tools/cpp-build-assistant.ts b/dbal/shared/tools/cpp-build-assistant.ts index fcd23a64b..7becf72bc 100644 --- a/dbal/shared/tools/cpp-build-assistant.ts +++ b/dbal/shared/tools/cpp-build-assistant.ts @@ -1,12 +1,15 @@ import path from 'path' -import { CppBuildAssistant, runCppBuildAssistant } from './cpp-build-assistant/index' +import { runCppBuildAssistant } from './cpp-build-assistant/runner' -export { CppBuildAssistant, runCppBuildAssistant } +export { CppBuildAssistant, createAssistant } from './cpp-build-assistant' +export { createCppBuildAssistantConfig } from './cpp-build-assistant/config' +export { runCppBuildAssistant } from './cpp-build-assistant/runner' if (require.main === module) { const args = process.argv.slice(2) + const projectRoot = path.join(__dirname, '..') - runCppBuildAssistant(args, path.join(__dirname, '..')) + runCppBuildAssistant(args, projectRoot) .then(success => { process.exit(success ? 0 : 1) }) diff --git a/dbal/shared/tools/cpp-build-assistant/cli.ts b/dbal/shared/tools/cpp-build-assistant/cli.ts new file mode 100644 index 000000000..c29c525a8 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/cli.ts @@ -0,0 +1,125 @@ +import os from 'os' +import { BuildType } from './config' +import { COLORS, log } from './logging' +import { CppBuildAssistant } from './index' + +export type CliCommand = + | 'check' + | 'init' + | 'install' + | 'configure' + | 'build' + | 'test' + | 'clean' + | 'rebuild' + | 'full' + | 'help' + +export interface ParsedCliArgs { + command: CliCommand + buildType: BuildType + jobs: number + target?: string + options: string[] +} + +const parseBuildType = (options: string[]): BuildType => (options.includes('--debug') ? 'Debug' : 'Release') + +const parseJobs = (options: string[]): number => { + const jobsArg = options.find(option => option.startsWith('--jobs=')) + const parsedJobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : Number.NaN + + return Number.isNaN(parsedJobs) ? os.cpus().length : parsedJobs +} + +const parseTarget = (command: CliCommand, options: string[]): string | undefined => { + if (command !== 'build') return undefined + + return options.find(option => !option.startsWith('--')) || 'all' +} + +export const parseCliArgs = (args: string[]): ParsedCliArgs => { + const command = (args[0] as CliCommand | undefined) || 'help' + const options = args.slice(1) + + return { + command, + buildType: parseBuildType(options), + jobs: parseJobs(options), + target: parseTarget(command, options), + options, + } +} + +export const showHelp = (): void => { + console.log(` +${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper + +${COLORS.cyan}USAGE:${COLORS.reset} + npm run cpp:build [command] [options] + +${COLORS.cyan}COMMANDS:${COLORS.reset} + ${COLORS.green}check${COLORS.reset} Check if all dependencies are installed + ${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing) + ${COLORS.green}install${COLORS.reset} Install Conan dependencies + ${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator + ${COLORS.green}build${COLORS.reset} [target] Build the project (default: all) + ${COLORS.green}test${COLORS.reset} Run tests with CTest + ${COLORS.green}clean${COLORS.reset} Remove build artifacts + ${COLORS.green}rebuild${COLORS.reset} Clean and rebuild + ${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build + ${COLORS.green}help${COLORS.reset} Show this help message + +${COLORS.cyan}OPTIONS:${COLORS.reset} + --debug Use Debug build type + --release Use Release build type (default) + --jobs=N Number of parallel build jobs (default: CPU count) + +${COLORS.cyan}EXAMPLES:${COLORS.reset} + npm run cpp:build check + npm run cpp:build full + npm run cpp:build build dbal_daemon + npm run cpp:build build -- --debug + npm run cpp:build test +`) +} + +export const runCli = async (args: string[], assistant: CppBuildAssistant): Promise => { + const parsed = parseCliArgs(args) + + switch (parsed.command) { + case 'check': + return assistant.checkDependencies() + case 'init': + return assistant.createConanfile() + case 'install': + if (!assistant.checkDependencies()) return false + return assistant.installConanDeps() + case 'configure': + if (!assistant.checkDependencies()) return false + return assistant.configureCMake(parsed.buildType) + case 'build': + if (!assistant.checkDependencies()) return false + return assistant.build(parsed.target, parsed.jobs) + case 'test': + return assistant.test() + case 'clean': + return assistant.clean() + case 'rebuild': + assistant.clean() + if (!assistant.checkDependencies()) return false + if (!assistant.configureCMake(parsed.buildType)) return false + return assistant.build('all', parsed.jobs) + case 'full': + log.section('Full Build Workflow') + if (!assistant.checkDependencies()) return false + if (!assistant.createConanfile()) return false + if (!assistant.installConanDeps()) return false + if (!assistant.configureCMake(parsed.buildType)) return false + return assistant.build('all', parsed.jobs) + case 'help': + default: + showHelp() + return true + } +} diff --git a/dbal/shared/tools/cpp-build-assistant/config.ts b/dbal/shared/tools/cpp-build-assistant/config.ts new file mode 100644 index 000000000..f160b8cc4 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/config.ts @@ -0,0 +1,20 @@ +import path from 'path' + +export type BuildType = 'Debug' | 'Release' + +export interface CppBuildAssistantConfig { + projectRoot: string + cppDir: string + buildDir: string +} + +export const createCppBuildAssistantConfig = (projectRoot?: string): CppBuildAssistantConfig => { + const resolvedProjectRoot = projectRoot || path.join(__dirname, '..') + const cppDir = path.join(resolvedProjectRoot, 'cpp') + + return { + projectRoot: resolvedProjectRoot, + cppDir, + buildDir: path.join(cppDir, 'build'), + } +} diff --git a/dbal/shared/tools/cpp-build-assistant/index.ts b/dbal/shared/tools/cpp-build-assistant/index.ts index 6810db714..a4f7500b8 100644 --- a/dbal/shared/tools/cpp-build-assistant/index.ts +++ b/dbal/shared/tools/cpp-build-assistant/index.ts @@ -1,18 +1,27 @@ import os from 'os' import path from 'path' +import { CppBuildAssistantConfig, BuildType, createCppBuildAssistantConfig } from './config' import { COLORS, log } from './logging' import { checkDependencies } from './dependencies' import { cleanBuild, configureCMake, ensureConanFile, execCommand, installConanDeps, buildTarget, runTests } from './workflow' export class CppBuildAssistant { - private projectRoot: string - private cppDir: string - private buildDir: string + private config: CppBuildAssistantConfig - constructor(projectRoot?: string) { - this.projectRoot = projectRoot || path.join(__dirname, '..') - this.cppDir = path.join(this.projectRoot, 'cpp') - this.buildDir = path.join(this.cppDir, 'build') + constructor(config?: CppBuildAssistantConfig) { + this.config = config || createCppBuildAssistantConfig() + } + + get projectRoot(): string { + return this.config.projectRoot + } + + get cppDir(): string { + return this.config.cppDir + } + + get buildDir(): string { + return this.config.buildDir } checkDependencies(): boolean { @@ -27,7 +36,7 @@ export class CppBuildAssistant { return installConanDeps(this.cppDir, execCommand) } - configureCMake(buildType: 'Debug' | 'Release' = 'Release'): boolean { + configureCMake(buildType: BuildType = 'Release'): boolean { return configureCMake(this.cppDir, buildType, execCommand) } @@ -42,88 +51,11 @@ export class CppBuildAssistant { clean(): boolean { return cleanBuild(this.buildDir) } - - async run(args: string[]): Promise { - const command = args[0] || 'help' - const options = args.slice(1) - - const buildType = options.includes('--debug') ? 'Debug' : 'Release' - const jobsArg = options.find(option => option.startsWith('--jobs=')) - const jobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : os.cpus().length - - switch (command) { - case 'check': - return this.checkDependencies() - case 'init': - return this.createConanfile() - case 'install': - if (!this.checkDependencies()) return false - return this.installConanDeps() - case 'configure': - if (!this.checkDependencies()) return false - return this.configureCMake(buildType as 'Debug' | 'Release') - case 'build': - if (!this.checkDependencies()) return false - const target = options.find(option => !option.startsWith('--')) || 'all' - return this.build(target, jobs) - case 'test': - return this.test() - case 'clean': - return this.clean() - case 'rebuild': - this.clean() - if (!this.checkDependencies()) return false - if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false - return this.build('all', jobs) - case 'full': - log.section('Full Build Workflow') - if (!this.checkDependencies()) return false - if (!this.createConanfile()) return false - if (!this.installConanDeps()) return false - if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false - return this.build('all', jobs) - case 'help': - default: - this.showHelp() - return true - } - } - - private showHelp(): void { - console.log(` -${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper - -${COLORS.cyan}USAGE:${COLORS.reset} - npm run cpp:build [command] [options] - -${COLORS.cyan}COMMANDS:${COLORS.reset} - ${COLORS.green}check${COLORS.reset} Check if all dependencies are installed - ${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing) - ${COLORS.green}install${COLORS.reset} Install Conan dependencies - ${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator - ${COLORS.green}build${COLORS.reset} [target] Build the project (default: all) - ${COLORS.green}test${COLORS.reset} Run tests with CTest - ${COLORS.green}clean${COLORS.reset} Remove build artifacts - ${COLORS.green}rebuild${COLORS.reset} Clean and rebuild - ${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build - ${COLORS.green}help${COLORS.reset} Show this help message - -${COLORS.cyan}OPTIONS:${COLORS.reset} - --debug Use Debug build type - --release Use Release build type (default) - --jobs=N Number of parallel build jobs (default: CPU count) - -${COLORS.cyan}EXAMPLES:${COLORS.reset} - npm run cpp:build check - npm run cpp:build full - npm run cpp:build build dbal_daemon - npm run cpp:build build -- --debug - npm run cpp:build test -`) - } } -export const runCppBuildAssistant = async (args: string[], projectRoot?: string) => { - const assistant = new CppBuildAssistant(projectRoot || path.join(__dirname, '..')) - return assistant.run(args) +export const createAssistant = (projectRoot?: string): CppBuildAssistant => { + const config = createCppBuildAssistantConfig(projectRoot || path.join(__dirname, '..')) + return new CppBuildAssistant(config) } + +export { BuildType, CppBuildAssistantConfig, COLORS, log } diff --git a/dbal/shared/tools/cpp-build-assistant/runner.ts b/dbal/shared/tools/cpp-build-assistant/runner.ts new file mode 100644 index 000000000..5d84827d2 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/runner.ts @@ -0,0 +1,10 @@ +import { CppBuildAssistant } from './index' +import { createCppBuildAssistantConfig } from './config' +import { runCli } from './cli' + +export const runCppBuildAssistant = async (args: string[], projectRoot?: string): Promise => { + const config = createCppBuildAssistantConfig(projectRoot) + const assistant = new CppBuildAssistant(config) + + return runCli(args, assistant) +} From fcd0e55125c2612a9c132185e14d67720cdce5d7 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:36:16 +0000 Subject: [PATCH 8/8] refactor: modularize ACL adapter strategies --- dbal/development/src/adapters/acl-adapter.ts | 4 +- .../src/adapters/acl-adapter/acl-adapter.ts | 86 +++++++++++++++++ .../src/adapters/acl-adapter/context.ts | 12 +-- .../src/adapters/acl-adapter/guards.ts | 2 +- .../src/adapters/acl-adapter/index.ts | 95 +------------------ .../src/adapters/acl-adapter/read-strategy.ts | 48 ++++++++++ .../src/adapters/acl-adapter/types.ts | 27 ++++++ .../adapters/acl-adapter/write-strategy.ts | 83 ++++++++++++++++ .../src/adapters/acl/audit-logger.ts | 2 +- .../src/adapters/acl/check-permission.ts | 2 +- .../adapters/acl/check-row-level-access.ts | 2 +- .../src/adapters/acl/default-rules.ts | 2 +- 12 files changed, 256 insertions(+), 109 deletions(-) create mode 100644 dbal/development/src/adapters/acl-adapter/acl-adapter.ts create mode 100644 dbal/development/src/adapters/acl-adapter/read-strategy.ts create mode 100644 dbal/development/src/adapters/acl-adapter/types.ts create mode 100644 dbal/development/src/adapters/acl-adapter/write-strategy.ts diff --git a/dbal/development/src/adapters/acl-adapter.ts b/dbal/development/src/adapters/acl-adapter.ts index a5bfa02e5..9fbcd7d83 100644 --- a/dbal/development/src/adapters/acl-adapter.ts +++ b/dbal/development/src/adapters/acl-adapter.ts @@ -1,3 +1,3 @@ -export { ACLAdapter } from './acl-adapter/index' -export type { User, ACLRule } from './acl/types' +export { ACLAdapter } from './acl-adapter' +export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './acl-adapter/types' export { defaultACLRules } from './acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/acl-adapter.ts b/dbal/development/src/adapters/acl-adapter/acl-adapter.ts new file mode 100644 index 000000000..9d2492451 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/acl-adapter.ts @@ -0,0 +1,86 @@ +import type { AdapterCapabilities, DBALAdapter } from '../adapter' +import type { ListOptions, ListResult } from '../../core/foundation/types' +import { createContext } from './context' +import { createReadStrategy } from './read-strategy' +import { createWriteStrategy } from './write-strategy' +import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' + +export class ACLAdapter implements DBALAdapter { + private readonly context: ACLContext + private readonly readStrategy: ReturnType + private readonly writeStrategy: ReturnType + + constructor(baseAdapter: DBALAdapter, user: User, options?: ACLAdapterOptions) { + this.context = createContext(baseAdapter, user, options) + this.readStrategy = createReadStrategy(this.context) + this.writeStrategy = createWriteStrategy(this.context) + } + + async create(entity: string, data: Record): Promise { + return this.writeStrategy.create(entity, data) + } + + async read(entity: string, id: string): Promise { + return this.readStrategy.read(entity, id) + } + + async update(entity: string, id: string, data: Record): Promise { + return this.writeStrategy.update(entity, id, data) + } + + async delete(entity: string, id: string): Promise { + return this.writeStrategy.delete(entity, id) + } + + async list(entity: string, options?: ListOptions): Promise> { + return this.readStrategy.list(entity, options) + } + + async findFirst(entity: string, filter?: Record): Promise { + return this.readStrategy.findFirst(entity, filter) + } + + async findByField(entity: string, field: string, value: unknown): Promise { + return this.readStrategy.findByField(entity, field, value) + } + + async upsert( + entity: string, + filter: Record, + createData: Record, + updateData: Record, + ): Promise { + return this.writeStrategy.upsert(entity, filter, createData, updateData) + } + + async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { + return this.writeStrategy.updateByField(entity, field, value, data) + } + + async deleteByField(entity: string, field: string, value: unknown): Promise { + return this.writeStrategy.deleteByField(entity, field, value) + } + + async createMany(entity: string, data: Record[]): Promise { + return this.writeStrategy.createMany(entity, data) + } + + async updateMany(entity: string, filter: Record, data: Record): Promise { + return this.writeStrategy.updateMany(entity, filter, data) + } + + async deleteMany(entity: string, filter?: Record): Promise { + return this.writeStrategy.deleteMany(entity, filter) + } + + async getCapabilities(): Promise { + return this.context.baseAdapter.getCapabilities() + } + + async close(): Promise { + await this.context.baseAdapter.close() + } +} + +export type { ACLAdapterOptions, ACLContext, ACLRule, User } +export { defaultACLRules } from '../acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/context.ts b/dbal/development/src/adapters/acl-adapter/context.ts index 9262dd64d..8213926b9 100644 --- a/dbal/development/src/adapters/acl-adapter/context.ts +++ b/dbal/development/src/adapters/acl-adapter/context.ts @@ -1,20 +1,12 @@ import type { DBALAdapter } from '../adapter' -import type { User, ACLRule } from '../acl/types' +import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' import { logAudit } from '../acl/audit-logger' import { defaultACLRules } from '../acl/default-rules' -export interface ACLContext { - baseAdapter: DBALAdapter - user: User - rules: ACLRule[] - auditLog: boolean - logger: (entity: string, operation: string, success: boolean, message?: string) => void -} - export const createContext = ( baseAdapter: DBALAdapter, user: User, - options?: { rules?: ACLRule[]; auditLog?: boolean }, + options?: ACLAdapterOptions, ): ACLContext => { const auditLog = options?.auditLog ?? true const rules = options?.rules || defaultACLRules diff --git a/dbal/development/src/adapters/acl-adapter/guards.ts b/dbal/development/src/adapters/acl-adapter/guards.ts index 8da05a011..be5171354 100644 --- a/dbal/development/src/adapters/acl-adapter/guards.ts +++ b/dbal/development/src/adapters/acl-adapter/guards.ts @@ -1,7 +1,7 @@ import { checkPermission } from '../acl/check-permission' import { checkRowLevelAccess } from '../acl/check-row-level-access' import { resolvePermissionOperation } from '../acl/resolve-permission-operation' -import type { ACLContext } from './context' +import type { ACLContext } from './types' export const enforcePermission = (context: ACLContext, entity: string, operation: string) => { checkPermission(entity, operation, context.user, context.rules, context.logger) diff --git a/dbal/development/src/adapters/acl-adapter/index.ts b/dbal/development/src/adapters/acl-adapter/index.ts index 354fe2a58..b356927a7 100644 --- a/dbal/development/src/adapters/acl-adapter/index.ts +++ b/dbal/development/src/adapters/acl-adapter/index.ts @@ -1,92 +1,3 @@ -import type { AdapterCapabilities, DBALAdapter } from '../adapter' -import type { ListOptions, ListResult } from '../../core/foundation/types' -import type { User, ACLRule } from '../acl/types' -import type { ACLContext } from './context' -import { createContext } from './context' -import { createEntity, deleteEntity, listEntities, readEntity, updateEntity } from './crud' -import { - createMany, - deleteByField, - deleteMany, - findByField, - findFirst, - updateByField, - updateMany, - upsert, -} from './bulk' - -export class ACLAdapter implements DBALAdapter { - private readonly context: ACLContext - - constructor(baseAdapter: DBALAdapter, user: User, options?: { rules?: ACLRule[]; auditLog?: boolean }) { - this.context = createContext(baseAdapter, user, options) - } - - async create(entity: string, data: Record): Promise { - return createEntity(this.context)(entity, data) - } - - async read(entity: string, id: string): Promise { - return readEntity(this.context)(entity, id) - } - - async update(entity: string, id: string, data: Record): Promise { - return updateEntity(this.context)(entity, id, data) - } - - async delete(entity: string, id: string): Promise { - return deleteEntity(this.context)(entity, id) - } - - async list(entity: string, options?: ListOptions): Promise> { - return listEntities(this.context)(entity, options) - } - - async findFirst(entity: string, filter?: Record): Promise { - return findFirst(this.context)(entity, filter) - } - - async findByField(entity: string, field: string, value: unknown): Promise { - return findByField(this.context)(entity, field, value) - } - - async upsert( - entity: string, - filter: Record, - createData: Record, - updateData: Record, - ): Promise { - return upsert(this.context)(entity, filter, createData, updateData) - } - - async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { - return updateByField(this.context)(entity, field, value, data) - } - - async deleteByField(entity: string, field: string, value: unknown): Promise { - return deleteByField(this.context)(entity, field, value) - } - - async createMany(entity: string, data: Record[]): Promise { - return createMany(this.context)(entity, data) - } - - async updateMany(entity: string, filter: Record, data: Record): Promise { - return updateMany(this.context)(entity, filter, data) - } - - async deleteMany(entity: string, filter?: Record): Promise { - return deleteMany(this.context)(entity, filter) - } - - async getCapabilities(): Promise { - return this.context.baseAdapter.getCapabilities() - } - - async close(): Promise { - await this.context.baseAdapter.close() - } -} - -export type { User, ACLRule } from './acl/types' -export { defaultACLRules } from './acl/default-rules' +export { ACLAdapter } from './acl-adapter' +export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' +export { defaultACLRules } from '../acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/read-strategy.ts b/dbal/development/src/adapters/acl-adapter/read-strategy.ts new file mode 100644 index 000000000..da2742e26 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/read-strategy.ts @@ -0,0 +1,48 @@ +import type { ListOptions, ListResult } from '../../core/foundation/types' +import { enforceRowAccess, resolveOperation, withAudit } from './guards' +import type { ACLContext } from './types' + +export const createReadStrategy = (context: ACLContext) => { + const read = async (entity: string, id: string): Promise => { + return withAudit(context, entity, 'read', async () => { + const result = await context.baseAdapter.read(entity, id) + if (result) { + enforceRowAccess(context, entity, 'read', result as Record) + } + return result + }) + } + + const list = async (entity: string, options?: ListOptions): Promise> => { + return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options)) + } + + const findFirst = async (entity: string, filter?: Record): Promise => { + const operation = resolveOperation('findFirst') + return withAudit(context, entity, operation, async () => { + const result = await context.baseAdapter.findFirst(entity, filter) + if (result) { + enforceRowAccess(context, entity, operation, result as Record) + } + return result + }) + } + + const findByField = async (entity: string, field: string, value: unknown): Promise => { + const operation = resolveOperation('findByField') + return withAudit(context, entity, operation, async () => { + const result = await context.baseAdapter.findByField(entity, field, value) + if (result) { + enforceRowAccess(context, entity, operation, result as Record) + } + return result + }) + } + + return { + read, + list, + findFirst, + findByField, + } +} diff --git a/dbal/development/src/adapters/acl-adapter/types.ts b/dbal/development/src/adapters/acl-adapter/types.ts new file mode 100644 index 000000000..ea4cf1857 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/types.ts @@ -0,0 +1,27 @@ +import type { DBALAdapter } from '../adapter' + +export interface User { + id: string + username: string + role: 'user' | 'admin' | 'god' | 'supergod' +} + +export interface ACLRule { + entity: string + roles: string[] + operations: string[] + rowLevelFilter?: (user: User, data: Record) => boolean +} + +export interface ACLAdapterOptions { + rules?: ACLRule[] + auditLog?: boolean +} + +export interface ACLContext { + baseAdapter: DBALAdapter + user: User + rules: ACLRule[] + auditLog: boolean + logger: (entity: string, operation: string, success: boolean, message?: string) => void +} diff --git a/dbal/development/src/adapters/acl-adapter/write-strategy.ts b/dbal/development/src/adapters/acl-adapter/write-strategy.ts new file mode 100644 index 000000000..cf8b6aff5 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/write-strategy.ts @@ -0,0 +1,83 @@ +import { enforceRowAccess, resolveOperation, withAudit } from './guards' +import type { ACLContext } from './types' + +export const createWriteStrategy = (context: ACLContext) => { + const create = async (entity: string, data: Record): Promise => { + return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data)) + } + + const update = async (entity: string, id: string, data: Record): Promise => { + return withAudit(context, entity, 'update', async () => { + const existing = await context.baseAdapter.read(entity, id) + if (existing) { + enforceRowAccess(context, entity, 'update', existing as Record) + } + return context.baseAdapter.update(entity, id, data) + }) + } + + const remove = async (entity: string, id: string): Promise => { + return withAudit(context, entity, 'delete', async () => { + const existing = await context.baseAdapter.read(entity, id) + if (existing) { + enforceRowAccess(context, entity, 'delete', existing as Record) + } + return context.baseAdapter.delete(entity, id) + }) + } + + const upsert = async ( + entity: string, + filter: Record, + createData: Record, + updateData: Record, + ): Promise => { + return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData)) + } + + const updateByField = async ( + entity: string, + field: string, + value: unknown, + data: Record, + ): Promise => { + const operation = resolveOperation('updateByField') + return withAudit(context, entity, operation, () => context.baseAdapter.updateByField(entity, field, value, data)) + } + + const deleteByField = async (entity: string, field: string, value: unknown): Promise => { + const operation = resolveOperation('deleteByField') + return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value)) + } + + const createMany = async (entity: string, data: Record[]): Promise => { + const operation = resolveOperation('createMany') + return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data)) + } + + const updateMany = async ( + entity: string, + filter: Record, + data: Record, + ): Promise => { + const operation = resolveOperation('updateMany') + return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data)) + } + + const deleteMany = async (entity: string, filter?: Record): Promise => { + const operation = resolveOperation('deleteMany') + return withAudit(context, entity, operation, () => context.baseAdapter.deleteMany(entity, filter)) + } + + return { + create, + update, + delete: remove, + upsert, + updateByField, + deleteByField, + createMany, + updateMany, + deleteMany, + } +} diff --git a/dbal/development/src/adapters/acl/audit-logger.ts b/dbal/development/src/adapters/acl/audit-logger.ts index f67a2736d..864c181e4 100644 --- a/dbal/development/src/adapters/acl/audit-logger.ts +++ b/dbal/development/src/adapters/acl/audit-logger.ts @@ -3,7 +3,7 @@ * @description Audit logging for ACL operations */ -import type { User } from './types' +import type { User } from '../acl-adapter/types' /** * Log audit entry for ACL operation diff --git a/dbal/development/src/adapters/acl/check-permission.ts b/dbal/development/src/adapters/acl/check-permission.ts index 3f1fd4a1b..b27a7b12d 100644 --- a/dbal/development/src/adapters/acl/check-permission.ts +++ b/dbal/development/src/adapters/acl/check-permission.ts @@ -4,7 +4,7 @@ */ import { DBALError } from '../../core/foundation/errors' -import type { User, ACLRule } from './types' +import type { ACLRule, User } from '../acl-adapter/types' /** * Check if user has permission to perform operation on entity diff --git a/dbal/development/src/adapters/acl/check-row-level-access.ts b/dbal/development/src/adapters/acl/check-row-level-access.ts index 3b3403205..70ea72fc7 100644 --- a/dbal/development/src/adapters/acl/check-row-level-access.ts +++ b/dbal/development/src/adapters/acl/check-row-level-access.ts @@ -4,7 +4,7 @@ */ import { DBALError } from '../../core/foundation/errors' -import type { User, ACLRule } from './types' +import type { ACLRule, User } from '../acl-adapter/types' /** * Check row-level access for specific data diff --git a/dbal/development/src/adapters/acl/default-rules.ts b/dbal/development/src/adapters/acl/default-rules.ts index a5ff3f3b0..25a6b803e 100644 --- a/dbal/development/src/adapters/acl/default-rules.ts +++ b/dbal/development/src/adapters/acl/default-rules.ts @@ -3,7 +3,7 @@ * @description Default ACL rules for entities */ -import type { ACLRule } from './types' +import type { ACLRule } from '../acl-adapter/types' export const defaultACLRules: ACLRule[] = [ {