#591 - database-users

This commit is contained in:
Saeed Vaziry
2025-05-21 17:21:10 +02:00
parent 2850c1fa59
commit fe3317692b
41 changed files with 1050 additions and 409 deletions

View File

@ -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'],

View File

@ -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<string, mixed> $input
* @return array<string, mixed>
*/
public static function rules(Server $server, array $input): array
public static function rules(Server $server): array
{
return [
'databases.*' => [

View File

@ -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);

View File

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Database\CreateDatabaseUser;
use App\Actions\Database\DeleteDatabaseUser;
use App\Actions\Database\LinkUser;
use App\Actions\Database\SyncDatabaseUsers;
use App\Http\Resources\DatabaseResource;
use App\Http\Resources\DatabaseUserResource;
use App\Models\DatabaseUser;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\RouteAttributes\Attributes\Delete;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Patch;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
use Spatie\RouteAttributes\Attributes\Put;
#[Prefix('servers/{server}/database/users')]
#[Middleware(['auth', 'has-project'])]
class DatabaseUserController extends Controller
{
#[Get('/', name: 'database-users')]
public function index(Server $server): Response
{
$this->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.');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Prefix;
#[Prefix('settings')]
#[Middleware(['auth'])]
class SettingController extends Controller
{
#[Get('/', name: 'settings')]
public function index(): RedirectResponse
{
return to_route('profile');
}
}

View File

@ -20,7 +20,7 @@
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
#[Prefix('users')]
#[Prefix('settings/users')]
#[Middleware(['auth'])]
class UserController extends Controller
{

View File

@ -76,6 +76,7 @@ public function share(Request $request): array
'sidebarOpen' => ! $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'),
],
];

View File

@ -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,
];

View File

@ -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,
];

View File

@ -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;
}

151
resources/css/base.css Normal file
View File

@ -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;
}
}

View File

@ -4,7 +4,7 @@ export default function AppLogoIconHtml({ className }: { className?: string }) {
return (
<div
className={cn(
'border-primary dark:bg-primary/60 from-primary to-primary/80 flex size-7 items-center justify-center rounded-md border bg-gradient-to-br font-bold text-white! shadow-xs dark:from-transparent dark:to-transparent',
'border-primary dark:bg-primary/60 from-primary to-primary/80 flex size-7 items-center justify-center rounded-md border bg-gradient-to-br font-sans text-2xl font-bold text-white! shadow-xs dark:from-transparent dark:to-transparent',
className,
)}
>

View File

@ -28,7 +28,7 @@ const mainNavItems: NavItem[] = [
},
{
title: 'Settings',
href: route('profile'),
href: route('settings'),
icon: CogIcon,
},
];

View File

@ -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<HTMLButtonElement>, VariantProps<typeof multiSelectVariants> {
/**
* 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<HTMLButtonElement, MultiSelectProps>(
(
{
options,
onValueChange,
variant,
defaultValue = [],
placeholder = 'Select options',
animation = 0,
maxCount = 3,
modalPopover = false,
className,
...props
},
ref,
) => {
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating] = React.useState(false);
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal={modalPopover}>
<PopoverTrigger asChild>
<Button
variant="outline"
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
'flex h-auto min-h-10 w-full items-center justify-between rounded-md border bg-inherit p-1 hover:bg-inherit [&_svg]:pointer-events-auto',
className,
)}
>
{selectedValues.length > 0 ? (
<div className="flex w-full items-center justify-between">
<div className="flex flex-wrap items-center">
{selectedValues.slice(0, maxCount).map((value) => {
const option = options.find((o) => o.value === value);
const IconComponent = option?.icon;
return (
<Badge
key={value}
className={cn(isAnimating ? 'animate-bounce' : '', multiSelectVariants({ variant }))}
style={{ animationDuration: `${animation}s` }}
onClick={(event) => {
event.stopPropagation();
toggleOption(value);
}}
>
{IconComponent && <IconComponent className="mr-2 h-4 w-4" />}
{option?.label}
</Badge>
);
})}
{selectedValues.length > maxCount && (
<Badge
className={cn(
'text-foreground border-foreground/1 bg-transparent hover:bg-transparent',
isAnimating ? 'animate-bounce' : '',
multiSelectVariants({ variant }),
)}
style={{ animationDuration: `${animation}s` }}
>
{`+ ${selectedValues.length - maxCount} more`}
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<XIcon
className="text-muted-foreground mx-2 h-4 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator orientation="vertical" className="flex h-full min-h-6" />
<ChevronDown className="text-muted-foreground mx-2 h-4 cursor-pointer" />
</div>
</div>
) : (
<div className="mx-auto flex w-full items-center justify-between">
<span className="text-muted-foreground mx-3 text-sm">{placeholder}</span>
<ChevronDown className="text-muted-foreground mx-2 h-4 cursor-pointer" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onEscapeKeyDown={() => setIsPopoverOpen(false)}>
<Command>
<CommandInput placeholder="Search..." onKeyDown={handleInputKeyDown} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
<CommandItem key="all" onSelect={toggleAll} className="cursor-pointer">
<div
className={cn(
'border-primary/40 dark:border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
selectedValues.length === options.length
? 'border-primary/40 dark:border-primary bg-primary/10 dark:bg-primary/30 text-primary/90 dark:text-foreground/90 border'
: 'opacity-50 [&_svg]:invisible',
)}
>
<CheckIcon className="text-primary dark:text-primary-foreground h-4 w-4" />
</div>
<span>(Select All)</span>
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem key={option.value} onSelect={() => toggleOption(option.value)} className="cursor-pointer">
<div
className={cn(
'border-primary/40 dark:border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
isSelected
? 'border-primary/40 dark:border-primary bg-primary/10 dark:bg-primary/30 text-primary/90 dark:text-foreground/90 border'
: 'opacity-50 [&_svg]:invisible',
)}
>
<CheckIcon className="text-primary dark:text-primary-foreground h-4 w-4" />
</div>
{option.icon && <option.icon className="text-muted-foreground mr-2 h-4 w-4" />}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues.length > 0 && (
<>
<CommandItem onSelect={handleClear} className="flex-1 cursor-pointer justify-center">
Clear
</CommandItem>
<Separator orientation="vertical" className="flex h-full min-h-6" />
</>
)}
<CommandItem onSelect={() => setIsPopoverOpen(false)} className="max-w-full flex-1 cursor-pointer justify-center">
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
},
);
MultiSelect.displayName = 'MultiSelect';

View File

@ -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',
},
},

View File

@ -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',

View File

@ -9,7 +9,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
'peer border-input dark:bg-input/30 data-[state=checked]:dark:border-primary data-[state=checked]:dark:bg-primary/20 data-[state=checked]:dark:text-foreground/90 data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary/90 data-[state=checked]:border-primary/40 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}

View File

@ -7,9 +7,9 @@ export function UserInfo({ user, showEmail = false }: { user: User; showEmail?:
return (
<>
<Avatar className="h-8 w-8 rounded-lg">
<Avatar className="h-8 w-8 rounded-md">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">{getInitials(user.name)}</AvatarFallback>
<AvatarFallback className="bg-accent text-accent-foreground border-ring rounded-md border">{getInitials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>

View File

@ -30,7 +30,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link className="block w-full" href={route('profile')} as="button" prefetch onClick={cleanup}>
<Link className="block w-full" href={route('settings')} as="button" prefetch onClick={cleanup}>
<Settings className="mr-2" />
Settings
</Link>

View File

@ -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) => <AppLayout {...props}>{children}</AppLayout>;

View File

@ -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<SharedData>();
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 (
<SidebarProvider
style={
@ -29,6 +36,7 @@ export default function Layout({
<SidebarInset>
<AppHeader />
<div className="flex flex-1 flex-col">{children}</div>
<Toaster richColors />
</SidebarInset>
</SidebarProvider>
);

View File

@ -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 (
<AuthLayoutTemplate title={title} description={description} {...props}>
{children}
<Toaster />
</AuthLayoutTemplate>
);
}

View File

@ -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 (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-md flex-col gap-6">
<Link href={route('home')} className="flex items-center gap-2 self-center font-medium">
<div className="flex h-9 w-9 items-center justify-center">
<AppLogoIcon className="size-9 fill-current text-black dark:text-white" />
</div>
</Link>
<div className="flex flex-col gap-6">
<Card className="rounded-xl">
<CardHeader className="px-10 pt-8 pb-0 text-center">
<CardTitle className="text-xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="px-10 py-8">{children}</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -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<AuthLayoutProps>) {
const { name, quote } = usePage<SharedData>().props;
return (
<div className="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r">
<div className="absolute inset-0 bg-zinc-900" />
<Link href={route('home')} className="relative z-20 flex items-center text-lg font-medium">
<AppLogoIcon className="mr-2 size-8 fill-current text-white" />
{name}
</Link>
{quote && (
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">&ldquo;{quote.message}&rdquo;</p>
<footer className="text-sm text-neutral-300">{quote.author}</footer>
</blockquote>
</div>
)}
</div>
<div className="w-full lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<Link href={route('home')} className="relative z-20 flex items-center justify-center lg:hidden">
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
</Link>
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
<h1 className="text-xl font-medium">{title}</h1>
<p className="text-muted-foreground text-sm text-balance">{description}</p>
</div>
{children}
</div>
</div>
</div>
);
}

View File

@ -8,7 +8,7 @@ interface AuthLayoutProps {
description?: string;
}
export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
export default function AuthLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
@ -16,7 +16,7 @@ export default function AuthSimpleLayout({ children, title, description }: Props
<div className="flex flex-col items-center gap-4">
<Link href={route('home')} className="flex flex-col items-center gap-2 font-medium">
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">
<AppLogoIcon className="size-9 rounded-sm fill-current text-[var(--foreground)] dark:text-white" />
<AppLogoIcon className="text-foreground size-9 rounded-sm fill-current" />
</div>
<span className="sr-only">{title}</span>
</Link>

View File

@ -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,
},
{

View File

@ -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<Required<{ password: string }>>({

View File

@ -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<Required<{ email: string }>>({

View File

@ -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;

View File

@ -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;

View File

@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Link</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Link database user [{databaseUser.username}]</DialogTitle>
<DialogDescription className="sr-only">Link database user</DialogDescription>
</DialogHeader>
<Form id="link-database-user" onSubmit={submit} className="p-4">
<FormFields>
<FormField>
<Label htmlFor="databases">Databases</Label>
<MultiSelect
options={databases}
onValueChange={(value) => form.setData('databases', value)}
defaultValue={form.data.databases}
placeholder="Select database"
variant="default"
maxCount={5}
/>
<InputError className="mt-2" message={form.errors.databases} />
</FormField>
</FormFields>
</Form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
Delete
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete database user [{databaseUser.username}]</DialogTitle>
<DialogDescription className="sr-only">Delete database user</DialogDescription>
</DialogHeader>
<p className="p-4">
Are you sure you want to delete database user <strong>{databaseUser.username}</strong>? This action cannot be undone.
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const columns: ColumnDef<DatabaseUser>[] = [
{
accessorKey: 'username',
header: 'Username',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'databases',
header: 'Linked databases',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return (
<div className="flex items-center">
{row.original.databases.map((database) => (
<Badge key={database} variant="outline" className="mr-1">
{database}
</Badge>
))}
</div>
);
},
},
{
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'status',
header: 'Status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
},
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link databaseUser={row.original} />
<DropdownMenuSeparator />
<Delete databaseUser={row.original} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];

View File

@ -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<CreateForm>({
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create database user</DialogTitle>
<DialogDescription className="sr-only">Create new database user</DialogDescription>
</DialogHeader>
<Form className="p-4" id="create-database-user-form" onSubmit={submit}>
<FormFields>
<FormField>
<Label htmlFor="username">Username</Label>
<Input
type="text"
id="username"
name="username"
value={form.data.username}
onChange={(e) => form.setData('username', e.target.value)}
/>
<InputError message={form.errors.username} />
</FormField>
<FormField>
<Label htmlFor="password">Password</Label>
<Input
type="password"
id="password"
name="password"
value={form.data.password}
onChange={(e) => form.setData('password', e.target.value)}
/>
<InputError message={form.errors.password} />
</FormField>
<FormField>
<div className="flex items-center space-x-3">
<Checkbox id="remote" name="remote" checked={form.data.remote} onClick={() => form.setData('remote', !form.data.remote)} />
<Label htmlFor="remote">Allow remote access</Label>
</div>
<InputError message={form.errors.remote} />
</FormField>
{form.data.remote && (
<FormField>
<Label htmlFor="host">Host</Label>
<Input type="text" id="host" name="host" value={form.data.host} onChange={(e) => form.setData('host', e.target.value)} />
<InputError message={form.errors.host} />
</FormField>
)}
</FormFields>
</Form>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="button" onClick={submit} disabled={form.processing}>
{form.processing && <LoaderCircle className="animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RefreshCwIcon />
<span className="hidden lg:block">Sync</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sync database users</DialogTitle>
<DialogDescription className="sr-only">Sync database users from the server to Vito.</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to sync the database users from the server to Vito?</p>
<DialogFooter>
<DialogTrigger asChild>
<Button variant="outline">Cancel</Button>
</DialogTrigger>
<Button disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Sync
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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<Page>();
return (
<ServerLayout server={page.props.server}>
<Head title={`Database users - ${page.props.server.name}`} />
<Container className="max-w-5xl">
<HeaderContainer>
<Heading title="Database users" description="Here you can manage the databases" />
<div className="flex items-center gap-2">
<a href="https://vitodeploy.com/docs/servers/database" target="_blank">
<Button variant="outline">
<BookOpenIcon />
<span className="hidden lg:block">Docs</span>
</Button>
</a>
<SyncUsers server={page.props.server} />
<CreateDatabaseUser server={page.props.server}>
<Button>
<PlusIcon />
<span className="hidden lg:block">Create</span>
</Button>
</CreateDatabaseUser>
</div>
</HeaderContainer>
<DataTable columns={columns} data={page.props.databaseUsers.data} />
</Container>
</ServerLayout>
);
}

View File

@ -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<Database>[] = [
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'status',
header: 'Status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
},
},
{
id: 'actions',
enableColumnFilter: false,

View File

@ -97,7 +97,7 @@ export default function CreateDatabase({ server, children }: { server: Server; c
<FormField>
<Label htmlFor="charset">Charset</Label>
<Select onValueChange={handleCharsetChange} defaultValue={form.data.charset}>
<SelectTrigger>
<SelectTrigger id="charset">
<SelectValue placeholder="Select charset" />
</SelectTrigger>
<SelectContent>
@ -113,7 +113,7 @@ export default function CreateDatabase({ server, children }: { server: Server; c
<FormField>
<Label htmlFor="collation">Collation</Label>
<Select onValueChange={(value) => form.setData('collation', value)} defaultValue={form.data.collation}>
<SelectTrigger>
<SelectTrigger id="collation">
<SelectValue placeholder="Select collation" />
</SelectTrigger>
<SelectContent>

13
resources/js/types/database-user.d.ts vendored Normal file
View File

@ -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;
}

View File

@ -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;

View File

@ -68,6 +68,11 @@ export interface SharedData {
projectServers: Server[];
server?: Server;
publicKeyText: string;
flash?: {
success: string;
error: string;
data: unknown;
};
[key: string]: unknown;
}

View File

@ -32,9 +32,8 @@
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="{{ asset('favicon/favicon-96x96.png') }}" sizes="any" />
<link rel="apple-touch-icon" href="{{ asset('favicon/apple-icon.png') }}" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />

View File

@ -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,