diff --git a/app/Actions/Database/CreateDatabaseUser.php b/app/Actions/Database/CreateDatabaseUser.php index 59b7a86f..c59de47a 100755 --- a/app/Actions/Database/CreateDatabaseUser.php +++ b/app/Actions/Database/CreateDatabaseUser.php @@ -7,6 +7,7 @@ use App\Models\Server; use App\Models\Service; use App\SSH\Services\Database\Database; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -20,6 +21,8 @@ class CreateDatabaseUser */ public function create(Server $server, array $input, array $links = []): DatabaseUser { + Validator::make($input, self::rules($server, $input))->validate(); + $databaseUser = new DatabaseUser([ 'server_id' => $server->id, 'username' => $input['username'], diff --git a/app/Actions/Database/LinkUser.php b/app/Actions/Database/LinkUser.php index 5a9f53cb..27362068 100755 --- a/app/Actions/Database/LinkUser.php +++ b/app/Actions/Database/LinkUser.php @@ -6,6 +6,7 @@ use App\Models\DatabaseUser; use App\Models\Server; use App\Models\Service; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -19,6 +20,8 @@ class LinkUser */ public function link(DatabaseUser $databaseUser, array $input): DatabaseUser { + Validator::make($input, self::rules($databaseUser->server))->validate(); + if (! isset($input['databases']) || ! is_array($input['databases'])) { $input['databases'] = []; } @@ -60,10 +63,9 @@ public function link(DatabaseUser $databaseUser, array $input): DatabaseUser } /** - * @param array $input * @return array */ - public static function rules(Server $server, array $input): array + public static function rules(Server $server): array { return [ 'databases.*' => [ diff --git a/app/Http/Controllers/API/DatabaseUserController.php b/app/Http/Controllers/API/DatabaseUserController.php index 7839a7ba..675a2e2e 100644 --- a/app/Http/Controllers/API/DatabaseUserController.php +++ b/app/Http/Controllers/API/DatabaseUserController.php @@ -51,8 +51,6 @@ public function create(Request $request, Project $project, Server $server): Data $this->validateRoute($project, $server); - $this->validate($request, CreateDatabaseUser::rules($server, $request->input())); - $databaseUser = app(CreateDatabaseUser::class)->create($server, $request->all()); return new DatabaseUserResource($databaseUser); @@ -80,8 +78,6 @@ public function link(Request $request, Project $project, Server $server, Databas $this->validateRoute($project, $server, $databaseUser); - $this->validate($request, LinkUser::rules($server, $request->all())); - $databaseUser = app(LinkUser::class)->link($databaseUser, $request->all()); return new DatabaseUserResource($databaseUser); diff --git a/app/Http/Controllers/DatabaseUserController.php b/app/Http/Controllers/DatabaseUserController.php new file mode 100644 index 00000000..4b77de1e --- /dev/null +++ b/app/Http/Controllers/DatabaseUserController.php @@ -0,0 +1,83 @@ +authorize('viewAny', [DatabaseUser::class, $server]); + + return Inertia::render('database-users/index', [ + 'databases' => DatabaseResource::collection($server->databases()->get()), + 'databaseUsers' => DatabaseUserResource::collection($server->databaseUsers()->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Post('/', name: 'database-users.store')] + public function store(Request $request, Server $server): RedirectResponse + { + $this->authorize('create', [DatabaseUser::class, $server]); + + app(CreateDatabaseUser::class)->create($server, $request->all()); + + return back() + ->with('success', 'Database user created successfully.'); + } + + #[Patch('/sync', name: 'database-users.sync')] + public function sync(Server $server): RedirectResponse + { + $this->authorize('create', [DatabaseUser::class, $server]); + + app(SyncDatabaseUsers::class)->sync($server); + + return back() + ->with('success', 'Database users synced successfully.'); + } + + #[Put('/link/{databaseUser}', name: 'database-users.link')] + public function link(Request $request, Server $server, DatabaseUser $databaseUser): RedirectResponse + { + $this->authorize('update', [$databaseUser, $server]); + + app(LinkUser::class)->link($databaseUser, $request->all()); + + return back() + ->with('success', 'Database user permissions updated.'); + } + + #[Delete('/{databaseUser}', name: 'database-users.destroy')] + public function destroy(Server $server, DatabaseUser $databaseUser): RedirectResponse + { + $this->authorize('delete', [$databaseUser, $server]); + + app(DeleteDatabaseUser::class)->delete($server, $databaseUser); + + return back() + ->with('success', 'Database user deleted successfully.'); + } +} diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php new file mode 100644 index 00000000..57dc107c --- /dev/null +++ b/app/Http/Controllers/SettingController.php @@ -0,0 +1,19 @@ + ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'flash' => [ 'success' => fn () => $request->session()->get('success'), + 'error' => fn () => $request->session()->get('error'), 'data' => fn () => $request->session()->get('data'), ], ]; diff --git a/app/Http/Resources/DatabaseResource.php b/app/Http/Resources/DatabaseResource.php index 890b09ff..8b2f89a4 100644 --- a/app/Http/Resources/DatabaseResource.php +++ b/app/Http/Resources/DatabaseResource.php @@ -21,7 +21,7 @@ public function toArray(Request $request): array 'collation' => $this->collation, 'charset' => $this->charset, 'status' => $this->status, - 'status_color' => $this::$statusColors[$this->status] ?? 'gray', + 'status_color' => Database::$statusColors[$this->status] ?? 'gray', 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Http/Resources/DatabaseUserResource.php b/app/Http/Resources/DatabaseUserResource.php index 651f7cd0..91494634 100644 --- a/app/Http/Resources/DatabaseUserResource.php +++ b/app/Http/Resources/DatabaseUserResource.php @@ -21,6 +21,7 @@ public function toArray(Request $request): array 'databases' => $this->databases, 'host' => $this->host, 'status' => $this->status, + 'status_color' => DatabaseUser::$statusColors[$this->status] ?? 'gray', 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/resources/css/app.css b/resources/css/app.css index 027a3a23..df583af3 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@import './base.css'; @plugin 'tailwindcss-animate'; @@ -7,239 +8,6 @@ @custom-variant dark (&:is(.dark *)); -@theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); - - --color-background: var(--background); - --color-foreground: var(--foreground); - - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - - --color-brand: var(--brand); - --color-badge-success: var(--badge-success); - --color-badge-success-foreground: var(--badge-success-foreground); - --color-badge-warning: var(--badge-warning); - --color-badge-warning-foreground: var(--badge-warning-foreground); - --color-badge-info: var(--badge-info); - --color-badge-info-foreground: var(--badge-info-foreground); - --color-badge-danger: var(--badge-danger); - --color-badge-danger-foreground: var(--badge-danger-foreground); - --color-badge-gray: var(--badge-gray); - --color-badge-gray-foreground: var(--badge-gray-foreground); - - --color-slate: var(--color-slate-500); - --color-gray: var(--color-gray-500); - --color-red: var(--color-red-500); - --color-orange: var(--color-orange-500); - --color-amber: var(--color-amber-500); - --color-yellow: var(--color-yellow-500); - --color-lime: var(--color-lime-500); - --color-green: var(--color-green-500); - --color-emerald: var(--color-emerald-500); - --color-teal: var(--color-teal-500); - --color-cyan: var(--color-cyan-500); - --color-sky: var(--color-sky-500); - --color-blue: var(--color-blue-500); - --color-indigo: var(--color-indigo-500); - --color-violet: var(--color-violet-500); - --color-purple: var(--color-purple-500); - --color-fuchsia: var(--color-fuchsia-500); - --color-pink: var(--color-pink-500); - --color-rose: var(--color-rose-500); -} - -/* - The default border color has changed to `currentColor` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. -*/ -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentColor); - } -} - -:root { - --brand: oklch(58.5% 0.233 277.117); - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(51.1% 0.262 276.966); - --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.145 0 0); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); - --success: var(--color-green-500); - --success-foreground: var(--color-green-100); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.87 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.87 0 0); - - --badge-success: var(--color-green-100); - --badge-success-foreground: var(--color-green-500); - --badge-warning: var(--color-yellow-100); - --badge-warning-foreground: var(--color-yellow-500); - --badge-info: var(--color-blue-100); - --badge-info-foreground: var(--color-blue-500); - --badge-danger: var(--color-red-100); - --badge-danger-foreground: var(--color-red-500); - --badge-gray: var(--color-gray-100); - --badge-gray-foreground: var(--color-gray-500); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.145 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.145 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(51.1% 0.262 276.966); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --success: var(--color-green-500); - --success-foreground: var(--color-green-300); - --border: oklch(0.269 0 0); - --input: oklch(0.269 0 0); - --ring: oklch(0.439 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.985 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.269 0 0); - --sidebar-ring: oklch(0.439 0 0); - - --badge-success: var(--color-green-700); - --badge-success-foreground: var(--color-green-300); - --badge-warning: var(--color-yellow-700); - --badge-warning-foreground: var(--color-yellow-300); - --badge-info: var(--color-blue-700); - --badge-info-foreground: var(--color-blue-300); - --badge-danger: var(--color-red-700); - --badge-danger-foreground: var(--color-red-300); - --badge-gray: var(--color-gray-700); - --badge-gray-foreground: var(--color-gray-300); -} - -@layer base { - * { - @apply border-border; - } - - body { - @apply bg-background text-foreground; - } -} - -@theme inline { - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); -} - -/* - ---break--- -*/ - -@layer base { - * { - @apply border-border outline-ring/50; - } - - body { - @apply bg-background text-foreground; - } +[data-slot='scroll-area-viewport'] div:first-child { + @apply h-full; } diff --git a/resources/css/base.css b/resources/css/base.css new file mode 100644 index 00000000..c9c62377 --- /dev/null +++ b/resources/css/base.css @@ -0,0 +1,151 @@ +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(51.1% 0.262 276.966); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.145 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.87 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.87 0 0); + + --brand: oklch(58.5% 0.233 277.117); + --success: var(--color-emerald-500); + --warning: var(--color-yellow-500); + --info: var(--color-blue-500); + --danger: var(--color-red-500); + --gray: var(--color-gray-500); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(51.1% 0.262 276.966); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.985 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --radius: 0.625rem; + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --color-brand: var(--brand); + --color-success: var(--success); + --color-warning: var(--warning); + --color-info: var(--info); + --color-danger: var(--danger); + --color-slate: var(--color-slate-500); + --color-gray: var(--color-gray-500); + --color-red: var(--color-red-500); + --color-orange: var(--color-orange-500); + --color-amber: var(--color-amber-500); + --color-yellow: var(--color-yellow-500); + --color-lime: var(--color-lime-500); + --color-green: var(--color-green-500); + --color-emerald: var(--color-emerald-500); + --color-teal: var(--color-teal-500); + --color-cyan: var(--color-cyan-500); + --color-sky: var(--color-sky-500); + --color-blue: var(--color-blue-500); + --color-indigo: var(--color-indigo-500); + --color-violet: var(--color-violet-500); + --color-purple: var(--color-purple-500); + --color-fuchsia: var(--color-fuchsia-500); + --color-pink: var(--color-pink-500); + --color-rose: var(--color-rose-500); +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/resources/js/components/app-logo-icon-html.tsx b/resources/js/components/app-logo-icon-html.tsx index f9edde75..8d816496 100644 --- a/resources/js/components/app-logo-icon-html.tsx +++ b/resources/js/components/app-logo-icon-html.tsx @@ -4,7 +4,7 @@ export default function AppLogoIconHtml({ className }: { className?: string }) { return (
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index a32f0994..d0267e0a 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -28,7 +28,7 @@ const mainNavItems: NavItem[] = [ }, { title: 'Settings', - href: route('profile'), + href: route('settings'), icon: CogIcon, }, ]; diff --git a/resources/js/components/multi-select.tsx b/resources/js/components/multi-select.tsx new file mode 100644 index 00000000..ad6b9970 --- /dev/null +++ b/resources/js/components/multi-select.tsx @@ -0,0 +1,277 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { CheckIcon, ChevronDown, XIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Separator } from '@/components/ui/separator'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command'; + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva('m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300', { + variants: { + variant: { + inverted: 'inverted', + }, + }, + defaultVariants: { + variant: 'inverted', + }, +}); + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps extends React.ButtonHTMLAttributes, VariantProps { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; + }[]; + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** The default selected values when the component mounts. */ + defaultValue?: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * Animation duration in seconds for the visual effects (e.g., bouncing badges). + * Optional, defaults to 0 (no animation). + */ + animation?: number; + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; +} + +export const MultiSelect = React.forwardRef( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = 'Select options', + animation = 0, + maxCount = 3, + modalPopover = false, + className, + ...props + }, + ref, + ) => { + const [selectedValues, setSelectedValues] = React.useState(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating] = React.useState(false); + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + setIsPopoverOpen(true); + } else if (event.key === 'Backspace' && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (option: string) => { + const newSelectedValues = selectedValues.includes(option) ? selectedValues.filter((value) => value !== option) : [...selectedValues, option]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + }; + + return ( + + + + + setIsPopoverOpen(false)}> + + + + No results found. + + +
+ +
+ (Select All) +
+ {options.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} className="cursor-pointer"> +
+ +
+ {option.icon && } + {option.label} +
+ ); + })} +
+ + +
+ {selectedValues.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} className="max-w-full flex-1 cursor-pointer justify-center"> + Close + +
+
+
+
+
+
+ ); + }, +); + +MultiSelect.displayName = 'MultiSelect'; diff --git a/resources/js/components/ui/badge.tsx b/resources/js/components/ui/badge.tsx index 640a060e..f414b962 100644 --- a/resources/js/components/ui/badge.tsx +++ b/resources/js/components/ui/badge.tsx @@ -5,16 +5,19 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const badgeVariants = cva( - 'uppercase inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto', + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto', { variants: { variant: { - default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', - success: 'border-badge-success text-badge-success-foreground [a&]:hover:bg-badge-success/90', - info: 'border-badge-info text-badge-info-foreground [a&]:hover:bg-badge-info/90', - warning: 'border-badge-warning text-badge-warning-foreground [a&]:hover:bg-badge-warning/90', - danger: 'border-badge-danger text-badge-danger-foreground [a&]:hover:bg-badge-danger/90', - gray: 'border-badge-gray text-badge-gray-foreground [a&]:hover:bg-badge-gray/90', + default: 'border border-primary/40 dark:border-primary bg-primary/10 dark:bg-primary/20 text-primary/90 dark:text-foreground/90', + success: 'border border-success/40 dark:border-success/60 bg-success/10 dark:bg-success/20 text-success/90 dark:text-foreground/90', + info: 'border border-info/40 dark:border-info/60 bg-info/10 dark:bg-info/20 text-info/90 dark:text-foreground/90', + warning: 'border border-warning/40 dark:border-warning/60 bg-warning/10 dark:bg-warning/20 text-warning/90 dark:text-foreground/90', + danger: + 'border border-destructive/40 dark:border-destructive/60 bg-destructive/10 dark:bg-destructive/20 text-destructive/90 dark:text-foreground/90', + destructive: + 'border border-destructive/40 dark:border-destructive/60 bg-destructive/10 dark:bg-destructive/20 text-destructive/90 dark:text-foreground/90', + gray: 'border border-gray/40 dark:border-gray/60 bg-gray/10 dark:bg-gray/20 text-gray/90 dark:text-foreground/90', outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', }, }, diff --git a/resources/js/components/ui/button.tsx b/resources/js/components/ui/button.tsx index c30aca7d..d5c8c969 100644 --- a/resources/js/components/ui/button.tsx +++ b/resources/js/components/ui/button.tsx @@ -10,9 +10,9 @@ const buttonVariants = cva( variants: { variant: { default: - 'shadow-lg dark:border dark:border-primary bg-primary/90 dark:bg-primary/60 text-primary-foreground dark:text-foreground/90 shadow-xs hover:bg-primary/90 dark:hover:bg-primary/80 focus-visible:ring-primary/20 dark:focus-visible:ring-primary/40', + 'shadow-lg border border-primary/40 dark:border-primary bg-primary/10 dark:bg-primary/30 text-primary/90 dark:text-foreground/90 shadow-xs hover:bg-primary/20 dark:hover:bg-primary/40 focus-visible:ring-primary/20 dark:focus-visible:ring-primary/40', destructive: - 'border border-destructive dark:bg-destructive/60 bg-destructive/70 text-white shadow-xs hover:bg-destructive/90 dark:hover:bg-destructive/80 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40', + 'border border-destructive/40 dark:bg-destructive/30 bg-destructive/10 text-destructive/70 dark:text-foreground/90 shadow-xs hover:bg-destructive/20 dark:hover:bg-destructive/40 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', diff --git a/resources/js/components/ui/checkbox.tsx b/resources/js/components/ui/checkbox.tsx index 0923759c..200636f3 100644 --- a/resources/js/components/ui/checkbox.tsx +++ b/resources/js/components/ui/checkbox.tsx @@ -9,7 +9,7 @@ function Checkbox({ className, ...props }: React.ComponentProps - + - {getInitials(user.name)} + {getInitials(user.name)}
{user.name} diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index d7db0476..9752c65f 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -30,7 +30,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) { - + Settings diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx deleted file mode 100644 index d1940576..00000000 --- a/resources/js/layouts/app-layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import AppLayout from '@/layouts/app/layout'; -import { type BreadcrumbItem } from '@/types'; -import { type ReactNode } from 'react'; - -interface AppLayoutProps { - children: ReactNode; - breadcrumbs?: BreadcrumbItem[]; -} - -export default ({ children, ...props }: AppLayoutProps) => {children}; diff --git a/resources/js/layouts/app/layout.tsx b/resources/js/layouts/app/layout.tsx index e5d11a1b..3d225a40 100644 --- a/resources/js/layouts/app/layout.tsx +++ b/resources/js/layouts/app/layout.tsx @@ -1,9 +1,11 @@ import { AppSidebar } from '@/components/app-sidebar'; import { AppHeader } from '@/components/app-header'; -import { type BreadcrumbItem, NavItem } from '@/types'; +import { type BreadcrumbItem, NavItem, SharedData } from '@/types'; import { CSSProperties, type PropsWithChildren } from 'react'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -import { usePoll } from '@inertiajs/react'; +import { usePage, usePoll } from '@inertiajs/react'; +import { Toaster } from '@/components/ui/sonner'; +import { toast } from 'sonner'; export default function Layout({ children, @@ -16,6 +18,11 @@ export default function Layout({ }>) { usePoll(10000); + const page = usePage(); + + if (page.props.flash && page.props.flash.success) toast.success(page.props.flash.success); + if (page.props.flash && page.props.flash.error) toast.error(page.props.flash.error); + return (
{children}
+
); diff --git a/resources/js/layouts/auth-layout.tsx b/resources/js/layouts/auth-layout.tsx deleted file mode 100644 index f442ee82..00000000 --- a/resources/js/layouts/auth-layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout'; -import { Toaster } from '@/components/ui/sonner'; - -export default function AuthLayout({ children, title, description, ...props }: { children: React.ReactNode; title: string; description: string }) { - return ( - - {children} - - - ); -} diff --git a/resources/js/layouts/auth/auth-card-layout.tsx b/resources/js/layouts/auth/auth-card-layout.tsx deleted file mode 100644 index 2da2ff6e..00000000 --- a/resources/js/layouts/auth/auth-card-layout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import AppLogoIcon from '@/components/app-logo-icon'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Link } from '@inertiajs/react'; -import { type PropsWithChildren } from 'react'; - -export default function AuthCardLayout({ - children, - title, - description, -}: PropsWithChildren<{ - name?: string; - title?: string; - description?: string; -}>) { - return ( -
-
- -
- -
- - -
- - - {title} - {description} - - {children} - -
-
-
- ); -} diff --git a/resources/js/layouts/auth/auth-split-layout.tsx b/resources/js/layouts/auth/auth-split-layout.tsx deleted file mode 100644 index 4da03d1b..00000000 --- a/resources/js/layouts/auth/auth-split-layout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import AppLogoIcon from '@/components/app-logo-icon'; -import { type SharedData } from '@/types'; -import { Link, usePage } from '@inertiajs/react'; -import { type PropsWithChildren } from 'react'; - -interface AuthLayoutProps { - title?: string; - description?: string; -} - -export default function AuthSplitLayout({ children, title, description }: PropsWithChildren) { - const { name, quote } = usePage().props; - - return ( -
-
-
- - - {name} - - {quote && ( -
-
-

“{quote.message}”

-
{quote.author}
-
-
- )} -
-
-
- - - -
-

{title}

-

{description}

-
- {children} -
-
-
- ); -} diff --git a/resources/js/layouts/auth/auth-simple-layout.tsx b/resources/js/layouts/auth/layout.tsx similarity index 82% rename from resources/js/layouts/auth/auth-simple-layout.tsx rename to resources/js/layouts/auth/layout.tsx index fd118493..d1f7a8ef 100644 --- a/resources/js/layouts/auth/auth-simple-layout.tsx +++ b/resources/js/layouts/auth/layout.tsx @@ -8,7 +8,7 @@ interface AuthLayoutProps { description?: string; } -export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren) { +export default function AuthLayout({ children, title, description }: PropsWithChildren) { return (
@@ -16,7 +16,7 @@ export default function AuthSimpleLayout({ children, title, description }: Props
- +
{title} diff --git a/resources/js/layouts/server/layout.tsx b/resources/js/layouts/server/layout.tsx index 69afad34..0f90b9cf 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -25,11 +25,12 @@ export default function ServerLayout({ server, children }: { server: Server; chi { title: 'Databases', href: route('databases', { server: server.id }), + onlyActivePath: route('databases', { server: server.id }), icon: DatabaseIcon, }, { title: 'Users', - href: '/users', + href: route('database-users', { server: server.id }), icon: UsersIcon, }, { diff --git a/resources/js/pages/auth/confirm-password.tsx b/resources/js/pages/auth/confirm-password.tsx index 62fbe0f1..cdad51c9 100644 --- a/resources/js/pages/auth/confirm-password.tsx +++ b/resources/js/pages/auth/confirm-password.tsx @@ -7,7 +7,7 @@ import InputError from '@/components/ui/input-error'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import AuthLayout from '@/layouts/auth-layout'; +import AuthLayout from '@/layouts/auth/layout'; export default function ConfirmPassword() { const { data, setData, post, processing, errors, reset } = useForm>({ diff --git a/resources/js/pages/auth/forgot-password.tsx b/resources/js/pages/auth/forgot-password.tsx index f7d4d8d0..1ae6e8a9 100644 --- a/resources/js/pages/auth/forgot-password.tsx +++ b/resources/js/pages/auth/forgot-password.tsx @@ -8,7 +8,7 @@ import TextLink from '@/components/text-link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import AuthLayout from '@/layouts/auth-layout'; +import AuthLayout from '@/layouts/auth/layout'; export default function ForgotPassword({ status }: { status?: string }) { const { data, setData, post, processing, errors } = useForm>({ diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index e7a22e70..d80f606b 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import AuthLayout from '@/layouts/auth-layout'; +import AuthLayout from '@/layouts/auth/layout'; type LoginForm = { email: string; diff --git a/resources/js/pages/auth/reset-password.tsx b/resources/js/pages/auth/reset-password.tsx index 54e0ae80..e062ed1e 100644 --- a/resources/js/pages/auth/reset-password.tsx +++ b/resources/js/pages/auth/reset-password.tsx @@ -6,7 +6,7 @@ import InputError from '@/components/ui/input-error'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import AuthLayout from '@/layouts/auth-layout'; +import AuthLayout from '@/layouts/auth/layout'; interface ResetPasswordProps { token: string; diff --git a/resources/js/pages/database-users/components/columns.tsx b/resources/js/pages/database-users/components/columns.tsx new file mode 100644 index 00000000..4862ae18 --- /dev/null +++ b/resources/js/pages/database-users/components/columns.tsx @@ -0,0 +1,199 @@ +import { ColumnDef } from '@tanstack/react-table'; +import DateTime from '@/components/date-time'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { useForm, usePage } from '@inertiajs/react'; +import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; +import FormSuccessful from '@/components/form-successful'; +import { useState } from 'react'; +import { DatabaseUser } from '@/types/database-user'; +import { Database } from '@/types/database'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/ui/input-error'; +import { MultiSelect } from '@/components/multi-select'; +import { Badge } from '@/components/ui/badge'; + +function Link({ databaseUser }: { databaseUser: DatabaseUser }) { + const [open, setOpen] = useState(false); + const page = usePage<{ + databases: Database[]; + }>(); + const form = useForm<{ + databases: string[]; + }>({ + databases: databaseUser.databases, + }); + + const databases = page.props.databases.map((database) => ({ + value: database.name, + label: database.name, + })); + + const submit = () => { + form.put(route('database-users.link', { server: databaseUser.server_id, databaseUser: databaseUser.id }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + + return ( + + + e.preventDefault()}>Link + + + + Link database user [{databaseUser.username}] + Link database user + + + + + + + + + + + ); +} + +function Delete({ databaseUser }: { databaseUser: DatabaseUser }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('database-users.destroy', { server: databaseUser.server_id, databaseUser: databaseUser.id }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete database user [{databaseUser.username}] + Delete database user + +

+ Are you sure you want to delete database user {databaseUser.username}? This action cannot be undone. +

+ + + + + + +
+
+ ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'username', + header: 'Username', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'databases', + header: 'Linked databases', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ( +
+ {row.original.databases.map((database) => ( + + {database} + + ))} +
+ ); + }, + }, + { + accessorKey: 'created_at', + header: 'Created at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: 'status', + header: 'Status', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.status}; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( +
+ + + + + + + + + + +
+ ); + }, + }, +]; diff --git a/resources/js/pages/database-users/components/create-database-user.tsx b/resources/js/pages/database-users/components/create-database-user.tsx new file mode 100644 index 00000000..d1b54fe3 --- /dev/null +++ b/resources/js/pages/database-users/components/create-database-user.tsx @@ -0,0 +1,115 @@ +import { Server } from '@/types/server'; +import React, { FormEvent, ReactNode, useState } from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { useForm } from '@inertiajs/react'; +import { Button } from '@/components/ui/button'; +import { LoaderCircle } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; +import { Checkbox } from '@/components/ui/checkbox'; + +type CreateForm = { + username: string; + password: string; + remote: boolean; + host: string; +}; + +export default function CreateDatabaseUser({ server, children }: { server: Server; children: ReactNode }) { + const [open, setOpen] = useState(false); + + const form = useForm({ + username: '', + password: '', + remote: false, + host: '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post(route('database-users.store', server.id), { + onSuccess: () => { + form.reset(); + setOpen(false); + }, + }); + }; + + const handleOpenChange = (open: boolean) => { + setOpen(open); + }; + + return ( + + {children} + + + Create database user + Create new database user + +
+ + + + form.setData('username', e.target.value)} + /> + + + + + form.setData('password', e.target.value)} + /> + + + +
+ form.setData('remote', !form.data.remote)} /> + +
+ +
+ {form.data.remote && ( + + + form.setData('host', e.target.value)} /> + + + )} +
+
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/database-users/components/sync-users.tsx b/resources/js/pages/database-users/components/sync-users.tsx new file mode 100644 index 00000000..4f02993f --- /dev/null +++ b/resources/js/pages/database-users/components/sync-users.tsx @@ -0,0 +1,45 @@ +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Server } from '@/types/server'; +import { Button } from '@/components/ui/button'; +import { LoaderCircleIcon, RefreshCwIcon } from 'lucide-react'; +import { useForm } from '@inertiajs/react'; +import { useState } from 'react'; + +export default function SyncUsers({ server }: { server: Server }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.patch(route('database-users.sync', server.id), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + + + + + Sync database users + Sync database users from the server to Vito. + +

Are you sure you want to sync the database users from the server to Vito?

+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/database-users/index.tsx b/resources/js/pages/database-users/index.tsx new file mode 100644 index 00000000..86a2644c --- /dev/null +++ b/resources/js/pages/database-users/index.tsx @@ -0,0 +1,54 @@ +import { Head, usePage } from '@inertiajs/react'; +import { Server } from '@/types/server'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import ServerLayout from '@/layouts/server/layout'; +import { DataTable } from '@/components/data-table'; +import React from 'react'; +import { BookOpenIcon, PlusIcon } from 'lucide-react'; +import CreateDatabaseUser from '@/pages/database-users/components/create-database-user'; +import SyncUsers from '@/pages/database-users/components/sync-users'; +import { DatabaseUser } from '@/types/database-user'; +import { columns } from '@/pages/database-users/components/columns'; + +type Page = { + server: Server; + databaseUsers: { + data: DatabaseUser[]; + }; +}; + +export default function Databases() { + const page = usePage(); + + return ( + + + + + + +
+ + + + + + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/databases/components/columns.tsx b/resources/js/pages/databases/components/columns.tsx index 1f54437f..0c4781ba 100644 --- a/resources/js/pages/databases/components/columns.tsx +++ b/resources/js/pages/databases/components/columns.tsx @@ -17,6 +17,7 @@ import { DatabaseIcon, LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; import FormSuccessful from '@/components/form-successful'; import { useState } from 'react'; import { Database } from '@/types/database'; +import { Badge } from '@/components/ui/badge'; function Delete({ database }: { database: Database }) { const [open, setOpen] = useState(false); @@ -95,6 +96,15 @@ export const columns: ColumnDef[] = [ return ; }, }, + { + accessorKey: 'status', + header: 'Status', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.status}; + }, + }, { id: 'actions', enableColumnFilter: false, diff --git a/resources/js/pages/databases/components/create-database.tsx b/resources/js/pages/databases/components/create-database.tsx index b8353bb9..58c4bf36 100644 --- a/resources/js/pages/databases/components/create-database.tsx +++ b/resources/js/pages/databases/components/create-database.tsx @@ -97,7 +97,7 @@ export default function CreateDatabase({ server, children }: { server: Server; c form.setData('collation', value)} defaultValue={form.data.collation}> - + diff --git a/resources/js/types/database-user.d.ts b/resources/js/types/database-user.d.ts new file mode 100644 index 00000000..b2909b99 --- /dev/null +++ b/resources/js/types/database-user.d.ts @@ -0,0 +1,13 @@ +export interface DatabaseUser { + id: number; + server_id: number; + username: string; + databases: string[]; + host?: string; + status: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; + created_at: string; + updated_at: string; + + [key: string]: unknown; +} diff --git a/resources/js/types/database.d.ts b/resources/js/types/database.d.ts index 56aad9f5..b688c527 100644 --- a/resources/js/types/database.d.ts +++ b/resources/js/types/database.d.ts @@ -5,7 +5,7 @@ export interface Database { collation: string; charset: string; status: string; - status_color: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; created_at: string; updated_at: string; diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 94ee369b..d9fd56a5 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -68,6 +68,11 @@ export interface SharedData { projectServers: Server[]; server?: Server; publicKeyText: string; + flash?: { + success: string; + error: string; + data: unknown; + }; [key: string]: unknown; } diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index e5e542dc..d0ab9c39 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -32,9 +32,8 @@ {{ config('app.name', 'Laravel') }} - - - + + diff --git a/tests/Feature/DatabaseUserTest.php b/tests/Feature/DatabaseUserTest.php index cdbbb850..089b2684 100644 --- a/tests/Feature/DatabaseUserTest.php +++ b/tests/Feature/DatabaseUserTest.php @@ -5,10 +5,8 @@ use App\Enums\DatabaseUserStatus; use App\Facades\SSH; use App\Models\DatabaseUser; -use App\Web\Pages\Servers\Databases\Users; -use App\Web\Pages\Servers\Databases\Widgets\DatabaseUsersList; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; +use Inertia\Testing\AssertableInertia; use Tests\TestCase; class DatabaseUserTest extends TestCase @@ -21,14 +19,13 @@ public function test_create_database_user(): void SSH::fake(); - Livewire::test(Users::class, [ + $this->post(route('database-users.store', [ 'server' => $this->server, + ]), [ + 'username' => 'user', + 'password' => 'password', ]) - ->callAction('create', [ - 'username' => 'user', - 'password' => 'password', - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('database_users', [ 'username' => 'user', @@ -42,16 +39,15 @@ public function test_create_database_user_with_remote(): void SSH::fake(); - Livewire::test(Users::class, [ + $this->post(route('database-users.store', [ 'server' => $this->server, + ]), [ + 'username' => 'user', + 'password' => 'password', + 'remote' => true, + 'host' => '%', ]) - ->callAction('create', [ - 'username' => 'user', - 'password' => 'password', - 'remote' => true, - 'host' => '%', - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('database_users', [ 'username' => 'user', @@ -64,17 +60,13 @@ public function test_see_database_users_list(): void { $this->actingAs($this->user); - $databaseUser = DatabaseUser::factory()->create([ + DatabaseUser::factory()->create([ 'server_id' => $this->server, ]); - $this->get( - Users::getUrl([ - 'server' => $this->server, - ]) - ) + $this->get(route('database-users', $this->server)) ->assertSuccessful() - ->assertSee($databaseUser->username); + ->assertInertia(fn (AssertableInertia $page) => $page->component('database-users/index')); } public function test_delete_database_user(): void @@ -87,11 +79,10 @@ public function test_delete_database_user(): void 'server_id' => $this->server, ]); - Livewire::test(DatabaseUsersList::class, [ + $this->delete(route('database-users.destroy', [ 'server' => $this->server, - ]) - ->callTableAction('delete', $databaseUser->id) - ->assertSuccessful(); + 'databaseUser' => $databaseUser, + ]))->assertSessionDoesntHaveErrors(); $this->assertDatabaseMissing('database_users', [ 'id' => $databaseUser->id, @@ -108,13 +99,12 @@ public function test_unlink_database(): void 'server_id' => $this->server, ]); - Livewire::test(DatabaseUsersList::class, [ + $this->put(route('database-users.link', [ 'server' => $this->server, - ]) - ->callTableAction('link', $databaseUser->id, [ - 'databases' => [], - ]) - ->assertSuccessful(); + 'databaseUser' => $databaseUser, + ]), [ + 'databases' => [], + ])->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('database_users', [ 'username' => $databaseUser->username,