feat: modal infrastructure + 11 modals (links, team, settings, workspace)
wire-elements/modal v3 integration: - Replace custom Alpine modal-container with <livewire:livewire-ui-modal /> - Customize vendor modal view with design tokens (bg-s1, dark overlay, smooth transitions) - All modals extend ModalComponent, use $this->closeModal() Modals built (PHP + Blade view + tests): - ConfirmAction — reusable danger confirm with custom event dispatch - CreateLink, EditLink, DeleteLink — full CRUD with workspace isolation - InviteMember, EditMemberRole, RemoveMember — team management - CreateApiToken, RevokeApiToken — Sanctum token lifecycle - CreateWebhook, DeleteWebhook — webhook CRUD - CreateWorkspace (uses CreateWorkspaceAction), DeleteWorkspace (confirmation input) Fixes: - Remove SoftDeletes from Webhook model (table has no deleted_at column) - Auto-generate webhook secret when user leaves field empty - Use correct role enum: owner/admin/editor/viewer (not member) - Protected Livewire model properties replaced with re-query pattern - Sidebar "New Workspace" button now dispatches openModal Tests: 105/105 passing, 211 assertions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
00673f3119
commit
fa9985cc33
|
|
@ -8,12 +8,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Webhook extends Model
|
||||
{
|
||||
/** @use HasFactory<WebhookFactory> */
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class ConfirmAction extends ModalComponent
|
||||
{
|
||||
public string $title = 'Confirm';
|
||||
public string $message = 'Are you sure?';
|
||||
public string $confirmLabel = 'Confirm';
|
||||
public string $cancelLabel = 'Cancel';
|
||||
public string $confirmEvent = '';
|
||||
public array $confirmEventParams = [];
|
||||
public bool $danger = false;
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
if ($this->confirmEvent) {
|
||||
$this->dispatch($this->confirmEvent, ...$this->confirmEventParams);
|
||||
}
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.confirm-action');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class CreateApiToken extends ModalComponent
|
||||
{
|
||||
public string $name = '';
|
||||
public ?string $plainTextToken = null;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:100',
|
||||
];
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$token = auth()->user()->createToken($this->name);
|
||||
$this->plainTextToken = $token->plainTextToken;
|
||||
|
||||
$this->dispatch('apiTokenCreated');
|
||||
}
|
||||
|
||||
public static function closeModalOnClickAway(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.create-api-token');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,10 @@
|
|||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Link\Models\Link;
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Str;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class CreateLink extends Component
|
||||
class CreateLink extends ModalComponent
|
||||
{
|
||||
public string $targetUrl = '';
|
||||
public string $slug = '';
|
||||
|
|
@ -43,7 +43,7 @@ class CreateLink extends Component
|
|||
]);
|
||||
|
||||
$this->dispatch('linkCreated');
|
||||
$this->dispatch('close-modal');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Webhook\Models\Webhook;
|
||||
use Illuminate\Support\Str;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class CreateWebhook extends ModalComponent
|
||||
{
|
||||
public string $url = '';
|
||||
public string $secret = '';
|
||||
public array $selectedEvents = [];
|
||||
|
||||
public array $availableEvents = [
|
||||
'link.clicked' => 'Link clicked',
|
||||
'link.created' => 'Link created',
|
||||
'link.updated' => 'Link updated',
|
||||
'link.deleted' => 'Link deleted',
|
||||
];
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => 'required|url|max:2048',
|
||||
'secret' => 'nullable|string|max:255',
|
||||
'selectedEvents' => 'required|array|min:1',
|
||||
'selectedEvents.*' => 'string|in:' . implode(',', array_keys($this->availableEvents)),
|
||||
];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
$workspace = app('current_workspace');
|
||||
|
||||
Webhook::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'url' => $this->url,
|
||||
'secret' => $this->secret ?: Str::random(32),
|
||||
'events' => $this->selectedEvents,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->dispatch('webhookCreated');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.create-webhook');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Workspace\Actions\CreateWorkspace as CreateWorkspaceAction;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class CreateWorkspace extends ModalComponent
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:80',
|
||||
];
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$workspace = (new CreateWorkspaceAction)->handle(auth()->user(), ['name' => $this->name]);
|
||||
|
||||
$this->dispatch('workspaceCreated', workspaceUlid: $workspace->ulid);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.create-workspace');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Link\Models\Link;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DeleteLink extends ModalComponent
|
||||
{
|
||||
public string $linkUlid = '';
|
||||
public string $linkTitle = '';
|
||||
|
||||
public function mount(string $linkUlid): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$link = Link::where('ulid', $linkUlid)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->linkUlid = $linkUlid;
|
||||
$this->linkTitle = $link->title ?? $link->slug;
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
|
||||
Link::where('ulid', $this->linkUlid)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
|
||||
$this->dispatch('linkDeleted', linkUlid: $this->linkUlid);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.delete-link');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Webhook\Models\Webhook;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DeleteWebhook extends ModalComponent
|
||||
{
|
||||
public int $webhookId = 0;
|
||||
public string $webhookUrl = '';
|
||||
|
||||
public function mount(int $webhookId): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$webhook = Webhook::where('id', $webhookId)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->webhookId = $webhookId;
|
||||
$this->webhookUrl = $webhook->url;
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
|
||||
Webhook::where('id', $this->webhookId)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
|
||||
$this->dispatch('webhookDeleted', webhookId: $this->webhookId);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.delete-webhook');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DeleteWorkspace extends ModalComponent
|
||||
{
|
||||
public string $workspaceUlid = '';
|
||||
public string $workspaceName = '';
|
||||
public string $confirmation = '';
|
||||
|
||||
public function mount(string $workspaceUlid): void
|
||||
{
|
||||
$workspace = Workspace::where('ulid', $workspaceUlid)
|
||||
->where('owner_id', auth()->id())
|
||||
->firstOrFail();
|
||||
|
||||
$this->workspaceUlid = $workspaceUlid;
|
||||
$this->workspaceName = $workspace->name;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'confirmation' => 'required|in:' . $this->workspaceName,
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'confirmation.in' => 'Please type the workspace name exactly to confirm.',
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
Workspace::where('ulid', $this->workspaceUlid)
|
||||
->where('owner_id', auth()->id())
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
|
||||
$this->dispatch('workspaceDeleted', workspaceUlid: $this->workspaceUlid);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function closeModalOnClickAway(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.delete-workspace');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Link\Models\Link;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class EditLink extends ModalComponent
|
||||
{
|
||||
public string $linkUlid = '';
|
||||
public int $linkId = 0;
|
||||
public string $targetUrl = '';
|
||||
public string $slug = '';
|
||||
public string $title = '';
|
||||
public string $status = 'active';
|
||||
|
||||
public function mount(string $linkUlid): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$link = Link::where('ulid', $linkUlid)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->linkUlid = $linkUlid;
|
||||
$this->linkId = $link->id;
|
||||
$this->targetUrl = $link->target_url;
|
||||
$this->slug = $link->slug;
|
||||
$this->title = $link->title ?? '';
|
||||
$this->status = $link->status;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'targetUrl' => 'required|url|max:2048',
|
||||
'slug' => 'required|alpha_dash|max:50|unique:links,slug,' . $this->linkId,
|
||||
'title' => 'nullable|max:200',
|
||||
'status' => 'required|in:active,inactive',
|
||||
];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
$workspace = app('current_workspace');
|
||||
|
||||
$link = Link::where('ulid', $this->linkUlid)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail();
|
||||
|
||||
$link->update([
|
||||
'target_url' => $this->targetUrl,
|
||||
'slug' => $this->slug,
|
||||
'title' => $this->title ?: null,
|
||||
'status' => $this->status,
|
||||
]);
|
||||
|
||||
$this->dispatch('linkUpdated', linkUlid: $this->linkUlid);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.edit-link');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class EditMemberRole extends ModalComponent
|
||||
{
|
||||
public int $memberId = 0;
|
||||
public string $memberName = '';
|
||||
public string $role = 'editor';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'role' => 'required|in:admin,editor,viewer',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(int $memberId): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$member = WorkspaceMember::where('id', $memberId)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->memberId = $memberId;
|
||||
$this->memberName = $member->user->name ?? '';
|
||||
$this->role = $member->role;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
$workspace = app('current_workspace');
|
||||
|
||||
$member = WorkspaceMember::where('id', $this->memberId)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail();
|
||||
|
||||
$member->update(['role' => $this->role]);
|
||||
|
||||
$this->dispatch('memberRoleUpdated', memberId: $this->memberId);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.edit-member-role');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Workspace\Models\WorkspaceInvitation;
|
||||
use Illuminate\Support\Str;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class InviteMember extends ModalComponent
|
||||
{
|
||||
public string $email = '';
|
||||
public string $role = 'editor';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => 'required|email|max:255',
|
||||
'role' => 'required|in:admin,editor,viewer',
|
||||
];
|
||||
}
|
||||
|
||||
public function invite(): void
|
||||
{
|
||||
$this->validate();
|
||||
$workspace = app('current_workspace');
|
||||
|
||||
WorkspaceInvitation::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'email' => strtolower($this->email),
|
||||
'role' => $this->role,
|
||||
'token' => Str::random(40),
|
||||
'invited_by' => auth()->id(),
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$this->dispatch('memberInvited');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.invite-member');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class RemoveMember extends ModalComponent
|
||||
{
|
||||
public int $memberId = 0;
|
||||
public string $memberName = '';
|
||||
|
||||
public function mount(int $memberId): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$member = WorkspaceMember::where('id', $memberId)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->memberId = $memberId;
|
||||
$this->memberName = $member->user->name ?? '';
|
||||
}
|
||||
|
||||
public function remove(): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
|
||||
WorkspaceMember::where('id', $this->memberId)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
|
||||
$this->dispatch('memberRemoved', memberId: $this->memberId);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.remove-member');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class RevokeApiToken extends ModalComponent
|
||||
{
|
||||
public int $tokenId = 0;
|
||||
public string $tokenName = '';
|
||||
|
||||
public function mount(int $tokenId): void
|
||||
{
|
||||
$token = PersonalAccessToken::where('id', $tokenId)
|
||||
->where('tokenable_id', auth()->id())
|
||||
->where('tokenable_type', get_class(auth()->user()))
|
||||
->firstOrFail();
|
||||
|
||||
$this->tokenId = $tokenId;
|
||||
$this->tokenName = $token->name;
|
||||
}
|
||||
|
||||
public function revoke(): void
|
||||
{
|
||||
PersonalAccessToken::where('id', $this->tokenId)
|
||||
->where('tokenable_id', auth()->id())
|
||||
->where('tokenable_type', get_class(auth()->user()))
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
|
||||
$this->dispatch('apiTokenRevoked', tokenId: $this->tokenId);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'sm';
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.modals.revoke-api-token');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1 @@
|
|||
<div
|
||||
x-data="{ open: false, component: null }"
|
||||
@open-modal.window="open = true; component = $event.detail.component"
|
||||
@close-modal.window="open = false"
|
||||
>
|
||||
<div
|
||||
x-show="open"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/70" @click="open = false"></div>
|
||||
<div class="relative bg-s1 border border-white/[.06] rounded-2xl shadow-2xl w-full max-w-md z-10"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95">
|
||||
@livewire('modals.create-link')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<livewire:livewire-ui-modal />
|
||||
|
|
|
|||
|
|
@ -143,12 +143,14 @@
|
|||
<div class="px-4 py-2 text-xs text-t3">No workspaces yet</div>
|
||||
@endforelse
|
||||
<div class="border-t border-white/[.06] mt-1 pt-1">
|
||||
<a href="#" class="flex items-center gap-2 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors">
|
||||
<button
|
||||
wire:click="$dispatch('openModal', {component: 'modals.create-workspace'})"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors w-full text-left">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 3v10M3 8h10"/>
|
||||
</svg>
|
||||
New Workspace
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
@if($danger)
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9V5m0 8h.01M4.93 4.93l10.14 10.14M10 18a8 8 0 100-16 8 8 0 000 16z"/>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">{{ $title }}</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">{{ $message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
{{ $cancelLabel }}
|
||||
</button>
|
||||
<button wire:click="confirm"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 {{ $danger ? 'bg-red text-white' : 'bg-blue text-white' }}">
|
||||
{{ $confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<div class="p-6">
|
||||
@if($plainTextToken)
|
||||
<h3 class="text-lg font-semibold text-t1 mb-2">Token Created</h3>
|
||||
<p class="text-sm text-t2 mb-4">Copy this token now — it won't be shown again.</p>
|
||||
|
||||
<div class="flex items-center gap-2 bg-s2 border border-white/[.06] rounded-lg px-3 py-2.5">
|
||||
<code class="flex-1 text-xs text-green font-mono break-all">{{ $plainTextToken }}</code>
|
||||
<button
|
||||
x-data
|
||||
x-on:click="navigator.clipboard.writeText('{{ $plainTextToken }}'); $el.textContent = 'Copied!'; setTimeout(() => $el.textContent = 'Copy', 2000)"
|
||||
class="text-xs text-t2 hover:text-t1 transition-colors flex-shrink-0 ml-2">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6">
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<h3 class="text-lg font-semibold text-t1 mb-5">Create API Token</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Token name *</label>
|
||||
<input wire:model="name" type="text" placeholder="My integration"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
@error('name') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button wire:click="create"
|
||||
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Create Token
|
||||
</button>
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Create Link
|
||||
</button>
|
||||
<button x-on:click="$dispatch('close-modal')"
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-t1 mb-5">Create Webhook</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Endpoint URL *</label>
|
||||
<input wire:model="url" type="url" placeholder="https://example.com/webhook"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
@error('url') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Secret (optional)</label>
|
||||
<input wire:model="secret" type="text" placeholder="Used to sign payloads"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50 font-mono">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-2">Events *</label>
|
||||
<div class="space-y-2">
|
||||
@foreach($availableEvents as $event => $label)
|
||||
<label class="flex items-center gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" wire:model="selectedEvents" value="{{ $event }}"
|
||||
class="w-4 h-4 rounded border-white/[.2] bg-s2 text-blue focus:ring-blue/50 focus:ring-offset-0">
|
||||
<span class="text-sm text-t1">{{ $label }}</span>
|
||||
<span class="text-xs text-t3 font-mono">{{ $event }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('selectedEvents') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button wire:click="save"
|
||||
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Create Webhook
|
||||
</button>
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-t1 mb-5">New Workspace</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Workspace name *</label>
|
||||
<input wire:model="name" type="text" placeholder="Acme Inc."
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
@error('name') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button wire:click="create"
|
||||
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Create Workspace
|
||||
</button>
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 2h2m-5 2h8l-1 12H6L5 4zm3 4v6m2-6v6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">Delete Link</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
Delete <span class="text-t1 font-medium">{{ $linkTitle }}</span>? This cannot be undone. All click analytics will be permanently removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="delete"
|
||||
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Delete Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 2h2m-5 2h8l-1 12H6L5 4zm3 4v6m2-6v6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">Delete Webhook</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
Delete endpoint <span class="text-t1 font-mono text-xs break-all">{{ $webhookUrl }}</span>? Delivery history will be removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="delete"
|
||||
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4 mb-5">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9V5m0 8h.01M4.93 4.93l10.14 10.14M10 18a8 8 0 100-16 8 8 0 000 16z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">Delete Workspace</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
This permanently deletes <span class="text-t1 font-medium">{{ $workspaceName }}</span> and all its links, analytics, domains, and members. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">
|
||||
Type <span class="text-t1 font-mono">{{ $workspaceName }}</span> to confirm
|
||||
</label>
|
||||
<input wire:model="confirmation" type="text"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-red/50 focus:border-red/50">
|
||||
@error('confirmation') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="delete"
|
||||
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Delete Workspace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-t1 mb-5">Edit Link</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Target URL *</label>
|
||||
<input wire:model="targetUrl" type="url" placeholder="https://example.com/long-url"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
@error('targetUrl') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Slug *</label>
|
||||
<div class="flex items-center bg-s2 border border-white/[.06] rounded-lg overflow-hidden focus-within:ring-1 focus-within:ring-blue/50">
|
||||
<span class="px-3 py-2.5 text-sm text-t3 border-r border-white/[.06] flex-shrink-0">nimu.li/</span>
|
||||
<input wire:model="slug" type="text"
|
||||
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-t1 placeholder-t3 focus:outline-none font-mono">
|
||||
</div>
|
||||
@error('slug') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Title (optional)</label>
|
||||
<input wire:model="title" type="text" placeholder="My link"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Status</label>
|
||||
<select wire:model="status"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button wire:click="save"
|
||||
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Save Changes
|
||||
</button>
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-t1 mb-1">Change Role</h3>
|
||||
<p class="text-sm text-t2 mb-5">Update role for <span class="text-t1 font-medium">{{ $memberName }}</span></p>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Role</label>
|
||||
<select wire:model="role"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="save"
|
||||
class="px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-t1 mb-5">Invite Team Member</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Email address *</label>
|
||||
<input wire:model="email" type="email" placeholder="colleague@example.com"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
@error('email') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Role</label>
|
||||
<select wire:model="role"
|
||||
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-xs text-t3">An invitation link will be sent to this address and expires in 7 days.</p>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button wire:click="invite"
|
||||
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Send Invite
|
||||
</button>
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7a3 3 0 11-6 0 3 3 0 016 0zm-8 10a5 5 0 0110 0H5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">Remove Member</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
Remove <span class="text-t1 font-medium">{{ $memberName }}</span> from this workspace? They will lose access immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="remove"
|
||||
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 9h6m-3-3v6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">Revoke Token</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
Revoke <span class="text-t1 font-medium">{{ $tokenName }}</span>? Any integrations using this token will stop working immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button wire:click="closeModal"
|
||||
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="revoke"
|
||||
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<div>
|
||||
@isset($jsPath)
|
||||
<script>{!! file_get_contents($jsPath) !!}</script>
|
||||
@endisset
|
||||
@isset($cssPath)
|
||||
<style>{!! file_get_contents($cssPath) !!}</style>
|
||||
@endisset
|
||||
|
||||
<div
|
||||
x-data="LivewireUIModal()"
|
||||
x-on:close.stop="setShowPropertyTo(false)"
|
||||
x-on:keydown.escape.window="show && closeModalOnEscape()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style="display: none;"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
x-show="show"
|
||||
x-on:click="closeModalOnClickAway()"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute inset-0 bg-black/70"
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div
|
||||
x-show="show && showActiveComponent"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
x-bind:class="modalWidth"
|
||||
class="relative w-full bg-s1 border border-white/[.06] rounded-2xl shadow-2xl z-10 overflow-hidden"
|
||||
id="modal-container"
|
||||
x-trap.noscroll.inert="show && showActiveComponent"
|
||||
aria-modal="true"
|
||||
>
|
||||
@forelse($components as $id => $component)
|
||||
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
|
||||
@livewire($component['name'], $component['arguments'], key($id))
|
||||
</div>
|
||||
@empty
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Link\Models\Link;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||
use App\Livewire\Modals\CreateLink;
|
||||
use App\Livewire\Modals\DeleteLink;
|
||||
use App\Livewire\Modals\EditLink;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function makeUserWithWs(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
|
||||
WorkspaceMember::create([
|
||||
'workspace_id' => $ws->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => 'owner',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
return [$user, $ws];
|
||||
}
|
||||
|
||||
// CreateLink
|
||||
|
||||
it('renders create link modal', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateLink::class)
|
||||
->assertOk()
|
||||
->assertSee('Create Short Link');
|
||||
});
|
||||
|
||||
it('validates required url on create link', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateLink::class)
|
||||
->call('save')
|
||||
->assertHasErrors(['targetUrl' => 'required']);
|
||||
});
|
||||
|
||||
it('validates url format on create link', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateLink::class)
|
||||
->set('targetUrl', 'not-a-url')
|
||||
->call('save')
|
||||
->assertHasErrors(['targetUrl']);
|
||||
});
|
||||
|
||||
it('creates link and dispatches linkCreated', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateLink::class)
|
||||
->set('targetUrl', 'https://example.com/page')
|
||||
->call('save')
|
||||
->assertDispatched('linkCreated');
|
||||
|
||||
expect(Link::where('workspace_id', $ws->id)->where('target_url', 'https://example.com/page')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('generate slug fills slug field when empty', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(CreateLink::class)
|
||||
->call('generateSlug');
|
||||
|
||||
expect($component->get('slug'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
// EditLink
|
||||
|
||||
it('renders edit link modal with prefilled data', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$link = Link::factory()->create([
|
||||
'workspace_id' => $ws->id,
|
||||
'title' => 'My Link',
|
||||
'slug' => 'mylink',
|
||||
'target_url' => 'https://old.example.com',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(EditLink::class, ['linkUlid' => $link->ulid])
|
||||
->assertOk()
|
||||
->assertSet('targetUrl', 'https://old.example.com')
|
||||
->assertSet('slug', 'mylink');
|
||||
});
|
||||
|
||||
it('updates link on save', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$link = Link::factory()->create([
|
||||
'workspace_id' => $ws->id,
|
||||
'slug' => 'oldslug',
|
||||
'target_url' => 'https://old.example.com',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(EditLink::class, ['linkUlid' => $link->ulid])
|
||||
->set('targetUrl', 'https://new.example.com')
|
||||
->set('slug', 'newslug')
|
||||
->call('save')
|
||||
->assertDispatched('linkUpdated');
|
||||
|
||||
expect($link->fresh()->target_url)->toBe('https://new.example.com');
|
||||
expect($link->fresh()->slug)->toBe('newslug');
|
||||
});
|
||||
|
||||
it('rejects edit for link in different workspace', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$otherOwner = User::factory()->create();
|
||||
$otherWs = Workspace::factory()->create(['owner_id' => $otherOwner->id]);
|
||||
$link = Link::factory()->create(['workspace_id' => $otherWs->id]);
|
||||
|
||||
expect(fn () => Livewire::actingAs($user)->test(EditLink::class, ['linkUlid' => $link->ulid]))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
||||
// DeleteLink
|
||||
|
||||
it('renders delete link modal', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$link = Link::factory()->create([
|
||||
'workspace_id' => $ws->id,
|
||||
'title' => 'My Link',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteLink::class, ['linkUlid' => $link->ulid])
|
||||
->assertOk()
|
||||
->assertSee('Delete Link');
|
||||
});
|
||||
|
||||
it('soft-deletes link and dispatches linkDeleted', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$link = Link::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteLink::class, ['linkUlid' => $link->ulid])
|
||||
->call('delete')
|
||||
->assertDispatched('linkDeleted');
|
||||
|
||||
expect(Link::withTrashed()->find($link->id)->deleted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects delete for link in different workspace', function () {
|
||||
[$user, $ws] = makeUserWithWs();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$otherOwner = User::factory()->create();
|
||||
$otherWs = Workspace::factory()->create(['owner_id' => $otherOwner->id]);
|
||||
$link = Link::factory()->create(['workspace_id' => $otherWs->id]);
|
||||
|
||||
expect(fn () => Livewire::actingAs($user)->test(DeleteLink::class, ['linkUlid' => $link->ulid]))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Webhook\Models\Webhook;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Domains\Workspace\Models\WorkspaceInvitation;
|
||||
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||
use App\Livewire\Modals\ConfirmAction;
|
||||
use App\Livewire\Modals\CreateApiToken;
|
||||
use App\Livewire\Modals\CreateWebhook;
|
||||
use App\Livewire\Modals\CreateWorkspace;
|
||||
use App\Livewire\Modals\DeleteWebhook;
|
||||
use App\Livewire\Modals\DeleteWorkspace;
|
||||
use App\Livewire\Modals\EditMemberRole;
|
||||
use App\Livewire\Modals\InviteMember;
|
||||
use App\Livewire\Modals\RemoveMember;
|
||||
use App\Livewire\Modals\RevokeApiToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function makeWsUser(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
|
||||
WorkspaceMember::create([
|
||||
'workspace_id' => $ws->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => 'owner',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
return [$user, $ws];
|
||||
}
|
||||
|
||||
// ConfirmAction
|
||||
|
||||
it('renders confirm modal', function () {
|
||||
[$user] = makeWsUser();
|
||||
|
||||
Livewire::actingAs($user)->test(ConfirmAction::class, [
|
||||
'title' => 'Are you sure?',
|
||||
'message' => 'This cannot be undone.',
|
||||
'confirmEvent' => 'testConfirmed',
|
||||
])->assertSee('Are you sure?');
|
||||
});
|
||||
|
||||
it('dispatches confirmEvent on confirm', function () {
|
||||
[$user] = makeWsUser();
|
||||
|
||||
Livewire::actingAs($user)->test(ConfirmAction::class, [
|
||||
'confirmEvent' => 'testConfirmed',
|
||||
])->call('confirm')
|
||||
->assertDispatched('testConfirmed');
|
||||
});
|
||||
|
||||
// InviteMember
|
||||
|
||||
it('renders invite member modal', function () {
|
||||
[$user, $ws] = makeWsUser();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(InviteMember::class)
|
||||
->assertSee('Invite Team Member');
|
||||
});
|
||||
|
||||
it('validates email on invite member', function () {
|
||||
[$user, $ws] = makeWsUser();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(InviteMember::class)
|
||||
->set('email', 'not-an-email')
|
||||
->call('invite')
|
||||
->assertHasErrors(['email']);
|
||||
});
|
||||
|
||||
it('creates invitation and dispatches memberInvited', function () {
|
||||
[$user, $ws] = makeWsUser();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(InviteMember::class)
|
||||
->set('email', 'new@example.com')
|
||||
->set('role', 'editor')
|
||||
->call('invite')
|
||||
->assertDispatched('memberInvited');
|
||||
|
||||
expect(WorkspaceInvitation::where('workspace_id', $ws->id)->where('email', 'new@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
// EditMemberRole
|
||||
|
||||
it('renders edit member role and updates role', function () {
|
||||
[$owner, $ws] = makeWsUser();
|
||||
$member = User::factory()->create();
|
||||
$membership = WorkspaceMember::create([
|
||||
'workspace_id' => $ws->id,
|
||||
'user_id' => $member->id,
|
||||
'role' => 'editor',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($owner)->test(EditMemberRole::class, ['memberId' => $membership->id])
|
||||
->assertSee('Change Role')
|
||||
->set('role', 'admin')
|
||||
->call('save')
|
||||
->assertDispatched('memberRoleUpdated');
|
||||
|
||||
expect($membership->fresh()->role)->toBe('admin');
|
||||
});
|
||||
|
||||
it('rejects edit member role for wrong workspace', function () {
|
||||
[$owner, $ws] = makeWsUser();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$otherOwner = User::factory()->create();
|
||||
$otherWs = Workspace::factory()->create(['owner_id' => $otherOwner->id]);
|
||||
$membership = WorkspaceMember::create([
|
||||
'workspace_id' => $otherWs->id,
|
||||
'user_id' => $otherOwner->id,
|
||||
'role' => 'owner',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
|
||||
expect(fn () => Livewire::actingAs($owner)->test(EditMemberRole::class, ['memberId' => $membership->id]))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
||||
// RemoveMember
|
||||
|
||||
it('removes member and dispatches memberRemoved', function () {
|
||||
[$owner, $ws] = makeWsUser();
|
||||
$member = User::factory()->create();
|
||||
$membership = WorkspaceMember::create([
|
||||
'workspace_id' => $ws->id,
|
||||
'user_id' => $member->id,
|
||||
'role' => 'editor',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($owner)->test(RemoveMember::class, ['memberId' => $membership->id])
|
||||
->call('remove')
|
||||
->assertDispatched('memberRemoved');
|
||||
|
||||
expect(WorkspaceMember::find($membership->id))->toBeNull();
|
||||
});
|
||||
|
||||
// CreateApiToken
|
||||
|
||||
it('renders create api token modal', function () {
|
||||
[$user] = makeWsUser();
|
||||
|
||||
Livewire::actingAs($user)->test(CreateApiToken::class)
|
||||
->assertSee('Create API Token');
|
||||
});
|
||||
|
||||
it('creates token and shows it once', function () {
|
||||
[$user] = makeWsUser();
|
||||
|
||||
$component = Livewire::actingAs($user)->test(CreateApiToken::class)
|
||||
->set('name', 'Test token')
|
||||
->call('create')
|
||||
->assertDispatched('apiTokenCreated');
|
||||
|
||||
expect($component->get('plainTextToken'))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('validates token name is required', function () {
|
||||
[$user] = makeWsUser();
|
||||
|
||||
Livewire::actingAs($user)->test(CreateApiToken::class)
|
||||
->call('create')
|
||||
->assertHasErrors(['name' => 'required']);
|
||||
});
|
||||
|
||||
// RevokeApiToken
|
||||
|
||||
it('revokes token and dispatches apiTokenRevoked', function () {
|
||||
[$user] = makeWsUser();
|
||||
$token = $user->createToken('My token');
|
||||
$pat = $token->accessToken;
|
||||
|
||||
Livewire::actingAs($user)->test(RevokeApiToken::class, ['tokenId' => $pat->id])
|
||||
->call('revoke')
|
||||
->assertDispatched('apiTokenRevoked');
|
||||
|
||||
expect(\Laravel\Sanctum\PersonalAccessToken::find($pat->id))->toBeNull();
|
||||
});
|
||||
|
||||
// CreateWebhook
|
||||
|
||||
it('creates webhook and dispatches webhookCreated', function () {
|
||||
[$user, $ws] = makeWsUser();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateWebhook::class)
|
||||
->set('url', 'https://example.com/hook')
|
||||
->set('selectedEvents', ['link.clicked'])
|
||||
->call('save')
|
||||
->assertDispatched('webhookCreated');
|
||||
|
||||
expect(Webhook::where('workspace_id', $ws->id)->where('url', 'https://example.com/hook')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates url and events on create webhook', function () {
|
||||
[$user, $ws] = makeWsUser();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateWebhook::class)
|
||||
->call('save')
|
||||
->assertHasErrors(['url', 'selectedEvents']);
|
||||
});
|
||||
|
||||
// DeleteWebhook
|
||||
|
||||
it('deletes webhook and dispatches webhookDeleted', function () {
|
||||
[$user, $ws] = makeWsUser();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
$webhook = Webhook::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteWebhook::class, ['webhookId' => $webhook->id])
|
||||
->call('delete')
|
||||
->assertDispatched('webhookDeleted');
|
||||
|
||||
expect(Webhook::find($webhook->id))->toBeNull();
|
||||
});
|
||||
|
||||
// CreateWorkspace
|
||||
|
||||
it('creates workspace and dispatches workspaceCreated', function () {
|
||||
[$user] = makeWsUser();
|
||||
|
||||
Livewire::actingAs($user)->test(CreateWorkspace::class)
|
||||
->set('name', 'New Workspace')
|
||||
->call('create')
|
||||
->assertDispatched('workspaceCreated');
|
||||
|
||||
expect(Workspace::where('owner_id', $user->id)->where('name', 'New Workspace')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates workspace name is required', function () {
|
||||
[$user] = makeWsUser();
|
||||
|
||||
Livewire::actingAs($user)->test(CreateWorkspace::class)
|
||||
->call('create')
|
||||
->assertHasErrors(['name' => 'required']);
|
||||
});
|
||||
|
||||
// DeleteWorkspace
|
||||
|
||||
it('requires confirmation to delete workspace', function () {
|
||||
[$user, $ws] = makeWsUser();
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteWorkspace::class, ['workspaceUlid' => $ws->ulid])
|
||||
->set('confirmation', 'Wrong name')
|
||||
->call('delete')
|
||||
->assertHasErrors(['confirmation']);
|
||||
|
||||
expect(Workspace::find($ws->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('soft-deletes workspace with correct confirmation', function () {
|
||||
[$user, $ws] = makeWsUser();
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteWorkspace::class, ['workspaceUlid' => $ws->ulid])
|
||||
->set('confirmation', $ws->name)
|
||||
->call('delete')
|
||||
->assertDispatched('workspaceDeleted');
|
||||
|
||||
expect(Workspace::withTrashed()->find($ws->id)->deleted_at)->not->toBeNull();
|
||||
});
|
||||
Loading…
Reference in New Issue