Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/instructions/frontend.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ App-level dialogs are **not** mounted inline next to their trigger. They live in
**The pieces:**
- `resources/js/components/dialogs/registry.ts` — maps a typed key to a dialog component.
- `resources/js/hooks/use-dialog.ts` — `useDialog()` returns `dialog.<key>.open(props)` / `.close()` with full prop typing.
- `resources/js/stores/dialog-store.ts` — Zustand store holding the single active dialog.
- `resources/js/components/dialogs/dialog-host.tsx` — renders the active dialog once, app-wide.
- `resources/js/stores/dialog-store.ts` — Zustand store holding the active dialog, plus an optional nested one on top of it.
- `resources/js/components/dialogs/dialog-host.tsx` — renders the active (and nested) dialog once, app-wide.

**Opening a dialog:**
```tsx
Expand All @@ -41,6 +41,11 @@ dialog.confirm.open({
});
```

**Opening a dialog on top of another — `openNested`:** `open()` replaces whatever dialog is showing. To open one over the current dialog without closing it (e.g. a guide from inside a form dialog, so unsaved input survives), use `dialog.<key>.openNested(props)`. The nested dialog closes itself through its own `onOpenChange` and returns to the one underneath; `dialog.<key>.close()` and closing the underlying dialog close both.
```tsx
dialog.setupGuide.openNested({ title: 'Ubuntu 26.04 Template', steps });
```

**Opening from a dropdown — this is the whole point of the pattern:** use a plain `DropdownMenuItem` with the default `onSelect` so the menu closes, then open the dialog. **Never** wrap a `<Dialog>`/`<DialogTrigger>` inside a `DropdownMenuItem` with `onSelect={(e) => e.preventDefault()}` — that leaves the dropdown stuck open behind the dialog.
```tsx
<DropdownMenuItem onSelect={() => dialog.editHostedDomain.open({ hostedDomain })}>Edit</DropdownMenuItem>
Expand Down
3 changes: 3 additions & 0 deletions app/Actions/Server/CreateServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

namespace App\Actions\Server;

use App\Enums\OperatingSystem;
use App\Jobs\Server\InstallJob;
use App\Models\Project;
use App\Models\Server;
use App\Models\ServerProvider;
use App\Models\User;
use App\ServerProviders\Custom;
use App\ValidationRules\RestrictedIPAddressesRule;
use App\ValidationRules\ServiceVersionAvailableRule;
use Exception;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Validator;
Expand Down Expand Up @@ -137,6 +139,7 @@ private function validate(Project $project, array $input): void
'services.*.version' => [
'string',
Rule::in(collect(config('service.services'))->pluck('versions')->flatten()->toArray()),
new ServiceVersionAvailableRule(is_string($input['os'] ?? null) ? OperatingSystem::tryFrom($input['os']) : null),
],
];

Expand Down
57 changes: 55 additions & 2 deletions app/Actions/ServerProvider/EditServerProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,35 @@
use App\Events\SocketEvent;
use App\Http\Resources\ServerProviderResource;
use App\Models\ServerProvider;
use App\ServerProviders\HasEditableCredentials;
use Exception;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;

class EditServerProvider
{
/**
* @param array<string, mixed> $input
*
* @throws ValidationException
*/
public function edit(ServerProvider $serverProvider, array $input, ?int $projectId): ServerProvider
{
Validator::make($input, [
$provider = $serverProvider->editableProvider();

Validator::make($input, array_merge([
'name' => [
'required',
],
])->validate();
], $provider?->editRules($input) ?? []))->validate();

$credentials = $provider?->editCredentials($input) ?? $serverProvider->credentials;

if ($provider && $credentials !== $serverProvider->credentials) {
$this->verify($serverProvider, $provider, $credentials, $input);
$serverProvider->credentials = $credentials;
}

$serverProvider->profile = $input['name'];
$serverProvider->project_id = $projectId;
Expand All @@ -34,4 +49,42 @@ public function edit(ServerProvider $serverProvider, array $input, ?int $project

return $serverProvider;
}

/**
* Errors on fields the edit form doesn't render are reported on `provider`,
* so they are still shown to the user.
*
* @param array<string, mixed> $credentials
* @param array<string, mixed> $input
*
* @throws ValidationException
*/
private function verify(ServerProvider $serverProvider, HasEditableCredentials $provider, array $credentials, array $input): void
{
try {
$provider->connect($credentials);
} catch (ValidationException $e) {
$fields = array_keys($provider->editRules($input));
$errors = [];

foreach ($e->errors() as $field => $messages) {
$key = in_array($field, $fields, true) ? $field : 'provider';
$errors[$key] = [...($errors[$key] ?? []), ...$messages];
}

throw ValidationException::withMessages($errors);
} catch (Exception $e) {
Log::error('Failed to verify server provider credentials', [
'server_provider_id' => $serverProvider->id,
'provider' => $serverProvider->provider,
'exception' => $e::class,
]);

throw ValidationException::withMessages([
'provider' => [
sprintf("Couldn't connect to %s. Please check your credentials.", $serverProvider->provider),
],
]);
}
}
}
6 changes: 4 additions & 2 deletions app/Actions/Service/Install.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Jobs\Service\InstallJob;
use App\Models\Server;
use App\Models\Service;
use App\ValidationRules\ServiceVersionAvailableRule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

Expand All @@ -19,7 +20,7 @@ class Install
*/
public function install(Server $server, array $input): Service
{
$this->validate($input);
$this->validate($server, $input);

$name = $input['name'];
$input['type'] = config("service.services.$name.type");
Expand Down Expand Up @@ -48,7 +49,7 @@ public function install(Server $server, array $input): Service
return $service;
}

private function validate(array $input): void
private function validate(Server $server, array $input): void
{
$installable = collect(config('service.services'))
->reject(fn (array $service): bool => ($service['type'] ?? null) === 'vpn')
Expand All @@ -62,6 +63,7 @@ private function validate(array $input): void
],
'version' => [
'required',
new ServiceVersionAvailableRule($server->os),
],
];
if (isset($input['name'])) {
Expand Down
11 changes: 11 additions & 0 deletions app/DTOs/DynamicField.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ public function alert(): self
public function guide(array $steps): self
{
$this->type = 'guide';

return $this->withGuide($steps);
}

/**
* Adds a button inside the input that opens a step-by-step guide.
*
* @param array<int, array{title: string, description?: string, code?: string}> $steps
*/
public function withGuide(array $steps): self
{
$this->componentProps = ['steps' => $steps];

return $this;
Expand Down
13 changes: 13 additions & 0 deletions app/Enums/OperatingSystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum OperatingSystem: string implements VitoEnum
case UBUNTU20 = 'ubuntu_20';
case UBUNTU22 = 'ubuntu_22';
case UBUNTU24 = 'ubuntu_24';
case UBUNTU26 = 'ubuntu_26';

public function getColor(): string
{
Expand All @@ -28,6 +29,18 @@ public function getVersion(): string
self::UBUNTU20 => '20.04',
self::UBUNTU22 => '22.04',
self::UBUNTU24 => '24.04',
self::UBUNTU26 => '26.04',
};
}

public function getCodename(): string
{
return match ($this) {
self::UBUNTU18 => 'bionic',
self::UBUNTU20 => 'focal',
self::UBUNTU22 => 'jammy',
self::UBUNTU24 => 'noble',
self::UBUNTU26 => 'resolute',
};
}
}
1 change: 1 addition & 0 deletions app/Http/Resources/ServerProviderResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public function toArray(Request $request): array
'global' => is_null($this->project_id),
'name' => $this->profile,
'provider' => $this->provider,
'editable_data' => $this->editableDataFor($request->user()),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
Expand Down
25 changes: 25 additions & 0 deletions app/Models/ServerProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Models;

use App\ServerProviders\HasEditableCredentials;
use App\Traits\HasProjectScopedQueries;
use Database\Factories\ServerProviderFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
Expand Down Expand Up @@ -77,6 +78,30 @@ public function provider(): \App\ServerProviders\ServerProvider
return $provider;
}

/**
* The handler, if users can edit this connection's credentials. The class is
* checked first, so providers whose plugin is gone never build a missing handler.
*/
public function editableProvider(): ?HasEditableCredentials
{
if (! is_a((string) config('server-provider.providers.'.$this->provider.'.handler'), HasEditableCredentials::class, true)) {
return null;
}

$provider = $this->provider();

return $provider instanceof HasEditableCredentials ? $provider : null;
}

public function editableDataFor(?User $user): object
{
if (! $user?->can('revealCredentials', $this)) {
return (object) [];
}

return (object) ($this->editableProvider()?->editableData() ?? []);
}

/**
* @return BelongsTo<Project, covariant $this>
*/
Expand Down
13 changes: 13 additions & 0 deletions app/Plugins/RegisterServerProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public function __construct(
private string $defaultUser = '',
private ?DynamicForm $createForm = null,
private ?int $provisionTimeout = null,
private ?DynamicForm $editForm = null,
) {}

public static function make(string $name): self
Expand Down Expand Up @@ -59,6 +60,17 @@ public function createForm(DynamicForm $createForm): self
return $this;
}

/**
* Credential fields users can change after connecting, rendered in the
* connection's edit form. Never include secrets here.
*/
public function editForm(DynamicForm $editForm): self
{
$this->editForm = $editForm;

return $this;
}

public function defaultUser(string $defaultUser): self
{
$this->defaultUser = $defaultUser;
Expand Down Expand Up @@ -87,6 +99,7 @@ public function register(): void
'form' => $this->form ? $this->form->toArray() : [],
'default_user' => $this->defaultUser,
'create_form' => $this->createForm ? $this->createForm->toArray() : [],
'edit_form' => $this->editForm ? $this->editForm->toArray() : [],
'provision_timeout' => $this->provisionTimeout,
];

Expand Down
18 changes: 17 additions & 1 deletion app/Plugins/RegisterServiceType.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class RegisterServiceType
* @param array<string> $versions
* @param array<string, mixed> $data
* @param array<int, array{name: string, path: string, sudo: bool}> $configPaths
* @param array<string, array<string>> $unavailableVersions
*/
public function __construct(
private string $name,
Expand All @@ -21,7 +22,8 @@ public function __construct(
private ?DynamicForm $form = null,
private array $versions = ['latest'],
private array $data = [],
private array $configPaths = []
private array $configPaths = [],
private array $unavailableVersions = []
) {}

public static function make(string $name): self
Expand Down Expand Up @@ -81,6 +83,19 @@ public function versions(array $versions): self
return $this;
}

/**
* Versions that can't be installed on an operating system, keyed by its
* OperatingSystem value.
*
* @param array<string, array<string>> $unavailableVersions
*/
public function unavailableVersions(array $unavailableVersions): self
{
$this->unavailableVersions = $unavailableVersions;

return $this;
}

/**
* @param array<string, mixed> $data
*/
Expand Down Expand Up @@ -116,6 +131,7 @@ public function register(): void
'handler' => $this->handler,
'form' => $this->form ? $this->form->toArray() : [],
'versions' => $this->versions,
'unavailable_versions' => $this->unavailableVersions,
'data' => $this->data,
'config_paths' => $this->configPaths,
];
Expand Down
18 changes: 18 additions & 0 deletions app/Policies/ServerProviderPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace App\Policies;

use App\Models\PersonalAccessToken;
use App\Models\ServerProvider;
use App\Models\User;
use App\Traits\ChecksTokenProjectScope;
use Illuminate\Auth\Access\HandlesAuthorization;
use Laravel\Sanctum\TransientToken;

class ServerProviderPolicy
{
Expand Down Expand Up @@ -34,6 +36,22 @@ public function update(User $user, ServerProvider $serverProvider): bool
&& $user->tokenAllowsProject($serverProvider->project_id, write: true);
}

/**
* Non-secret credential values are only for callers who can already
* rewrite them. API tokens must additionally carry the write ability.
*/
public function revealCredentials(User $user, ServerProvider $serverProvider): bool
{
/** @var PersonalAccessToken|TransientToken|null $token */
$token = $user->currentAccessToken();

if ($token !== null && ! $token->can('write')) {
return false;
}

return $this->update($user, $serverProvider);
}

public function delete(User $user, ServerProvider $serverProvider): bool
{
return $user->id === $serverProvider->user_id
Expand Down
Loading
Loading