feat: add profile, team, settings subpages

- Profile pages: PersonalInfo, Preferences, Security
- Team: Members list with role management stub
- Settings: ApiTokens (Sanctum, one-time display), Webhooks stub
- Settings/Index: workspace name/slug update
- Sidebar: Team nav item + SVG icon
- Topbar: profile link → profile.personal
- Routes: profile.personal, profile.preferences, profile.security, w.team.index, w.settings.api-tokens, w.settings.webhooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 11:07:31 +02:00
parent b86bdd772a
commit 52d52824bc
17 changed files with 543 additions and 27 deletions

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Pages\Profile;
use Illuminate\View\View;
use Livewire\Component;
class PersonalInfo extends Component
{
public string $name = '';
public string $email = '';
public function mount(): void
{
$this->name = auth()->user()->name;
$this->email = auth()->user()->email;
}
public function save(): void
{
$this->validate([
'name' => 'required|string|max:100',
'email' => 'required|email|max:200|unique:users,email,' . auth()->id(),
]);
auth()->user()->update([
'name' => $this->name,
'email' => $this->email,
]);
session()->flash('status', 'Profile updated.');
}
public function render(): View
{
return view('livewire.pages.profile.personal-info')
->layout('layouts.nimuli-app', ['title' => 'Profile']);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Pages\Profile;
use Illuminate\View\View;
use Livewire\Component;
class Preferences extends Component
{
public string $locale = 'de';
public string $theme = 'dark';
public function mount(): void
{
$this->locale = auth()->user()->locale ?? 'de';
$this->theme = auth()->user()->theme ?? 'dark';
}
public function save(): void
{
$this->validate([
'locale' => 'required|in:de,en',
'theme' => 'required|in:dark,light,system',
]);
auth()->user()->update([
'locale' => $this->locale,
'theme' => $this->theme,
]);
session()->flash('status', 'Preferences saved.');
}
public function render(): View
{
return view('livewire.pages.profile.preferences')
->layout('layouts.nimuli-app', ['title' => 'Preferences']);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Pages\Profile;
use Illuminate\View\View;
use Livewire\Component;
use Illuminate\Support\Facades\Hash;
class Security extends Component
{
public string $currentPassword = '';
public string $newPassword = '';
public string $newPasswordConfirmation = '';
public function changePassword(): void
{
$this->validate([
'currentPassword' => 'required',
'newPassword' => 'required|min:8|confirmed',
]);
if (!Hash::check($this->currentPassword, auth()->user()->password)) {
$this->addError('currentPassword', 'Current password is incorrect.');
return;
}
auth()->user()->update(['password' => Hash::make($this->newPassword)]);
$this->reset(['currentPassword', 'newPassword', 'newPasswordConfirmation']);
session()->flash('status', 'Password changed successfully.');
}
public function render(): View
{
return view('livewire.pages.profile.security')
->layout('layouts.nimuli-app', ['title' => 'Security']);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Livewire\Pages\Settings;
use Illuminate\View\View;
use Livewire\Component;
use Laravel\Sanctum\PersonalAccessToken;
class ApiTokens extends Component
{
public string $tokenName = '';
public ?string $newToken = null;
public function createToken(): void
{
$this->validate(['tokenName' => 'required|string|max:100']);
$token = auth()->user()->createToken($this->tokenName);
$this->newToken = $token->plainTextToken;
$this->tokenName = '';
}
public function revokeToken(int $id): void
{
auth()->user()->tokens()->where('id', $id)->delete();
}
public function dismissNewToken(): void
{
$this->newToken = null;
}
public function render(): View
{
$tokens = auth()->user()->tokens()->latest()->get();
return view('livewire.pages.settings.api-tokens', compact('tokens'))
->layout('layouts.nimuli-app', ['title' => 'API Tokens']);
}
}

View File

@ -7,6 +7,31 @@ use Livewire\Component;
class Index extends Component class Index extends Component
{ {
public string $workspaceName = '';
public string $workspaceSlug = '';
public function mount(): void
{
$ws = app('current_workspace');
$this->workspaceName = $ws->name;
$this->workspaceSlug = $ws->slug;
}
public function saveWorkspace(): void
{
$this->validate([
'workspaceName' => 'required|string|max:100',
'workspaceSlug' => 'nullable|string|max:50|alpha_dash',
]);
app('current_workspace')->update([
'name' => $this->workspaceName,
'slug' => $this->workspaceSlug ?: app('current_workspace')->slug,
]);
session()->flash('status', 'Workspace settings saved.');
}
public function render(): View public function render(): View
{ {
return view('livewire.pages.settings.index') return view('livewire.pages.settings.index')

View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire\Pages\Settings;
use Illuminate\View\View;
use Livewire\Component;
class Webhooks extends Component
{
public function render(): View
{
return view('livewire.pages.settings.webhooks')
->layout('layouts.nimuli-app', ['title' => 'Webhooks']);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Livewire\Pages\Team;
use App\Domains\Workspace\Models\WorkspaceMember;
use Illuminate\View\View;
use Livewire\Component;
class Members extends Component
{
public function render(): View
{
$workspace = app('current_workspace');
$members = WorkspaceMember::with('user')
->where('workspace_id', $workspace->id)
->get();
return view('livewire.pages.team.members', compact('members'))
->layout('layouts.nimuli-app', ['title' => 'Team']);
}
}

View File

@ -61,6 +61,13 @@
'match' => 'w.billing.*', 'match' => 'w.billing.*',
'icon' => 'billing', 'icon' => 'billing',
], ],
[
'label' => 'Team',
'route' => 'w.team.index',
'params' => [$wsUlid],
'match' => 'w.team.*',
'icon' => 'team',
],
] : [ ] : [
[ [
'label' => 'Dashboard', 'label' => 'Dashboard',
@ -193,6 +200,10 @@
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5"> <svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<rect x="1.5" y="3.5" width="13" height="9" rx="1.5"/><path stroke-linecap="round" d="M1.5 6.5h13"/> <rect x="1.5" y="3.5" width="13" height="9" rx="1.5"/><path stroke-linecap="round" d="M1.5 6.5h13"/>
</svg> </svg>
@elseif($item['icon'] === 'team')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="5" r="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M1.5 14c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"/><circle cx="11.5" cy="5" r="1.5"/><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 13c0-1.8-1-3-2.5-3.5"/>
</svg>
@endif @endif
</span> </span>
{{ $item['label'] }} {{ $item['label'] }}

View File

@ -99,7 +99,7 @@
<!-- Links --> <!-- Links -->
<div class="py-1"> <div class="py-1">
<a <a
href="{{ route('profile') }}" href="{{ route('profile.personal') }}"
wire:navigate wire:navigate
@click="userOpen = false" @click="userOpen = false"
class="flex items-center gap-2.5 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors" class="flex items-center gap-2.5 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors"

View File

@ -0,0 +1,36 @@
<div class="max-w-2xl">
<div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Profile</h1>
<nav class="flex gap-1 mt-4">
<a href="{{ route('profile.personal') }}" class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Personal Info</a>
<a href="{{ route('profile.preferences') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Preferences</a>
<a href="{{ route('profile.security') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Security</a>
</nav>
</div>
@if(session('status'))
<div class="mb-4 p-3 rounded-lg bg-green/10 border border-green/20 text-sm text-green">{{ session('status') }}</div>
@endif
<div class="bg-s1 border border-white/[.06] rounded-xl p-6">
<h3 class="text-sm font-medium text-t1 mb-5">Personal Information</h3>
<div class="space-y-4 max-w-sm">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Name</label>
<input wire:model="name" type="text"
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">
@error('name') <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">Email</label>
<input wire:model="email" type="email"
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">
@error('email') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<button wire:click="save"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Save Changes
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,46 @@
<div class="max-w-2xl">
<div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Profile</h1>
<nav class="flex gap-1 mt-4">
<a href="{{ route('profile.personal') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Personal Info</a>
<a href="{{ route('profile.preferences') }}" class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Preferences</a>
<a href="{{ route('profile.security') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Security</a>
</nav>
</div>
@if(session('status'))
<div class="mb-4 p-3 rounded-lg bg-green/10 border border-green/20 text-sm text-green">{{ session('status') }}</div>
@endif
<div class="bg-s1 border border-white/[.06] rounded-xl p-6">
<h3 class="text-sm font-medium text-t1 mb-5">Preferences</h3>
<div class="space-y-5 max-w-sm">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Language</label>
<select wire:model="locale"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-2">Theme</label>
<div class="flex gap-2">
@foreach(['dark' => 'Dark', 'light' => 'Light', 'system' => 'System'] as $val => $label)
<label class="flex-1 cursor-pointer">
<input wire:model="theme" type="radio" value="{{ $val }}" class="sr-only">
<div class="px-3 py-2 text-sm text-center rounded-lg border transition-colors
{{ $theme === $val ? 'bg-blue/10 border-blue/40 text-blue' : 'bg-s2 border-white/[.06] text-t2 hover:text-t1' }}">
{{ $label }}
</div>
</label>
@endforeach
</div>
</div>
<button wire:click="save"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90">
Save Preferences
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,41 @@
<div class="max-w-2xl">
<div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Profile</h1>
<nav class="flex gap-1 mt-4">
<a href="{{ route('profile.personal') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Personal Info</a>
<a href="{{ route('profile.preferences') }}" class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Preferences</a>
<a href="{{ route('profile.security') }}" class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Security</a>
</nav>
</div>
@if(session('status'))
<div class="mb-4 p-3 rounded-lg bg-green/10 border border-green/20 text-sm text-green">{{ session('status') }}</div>
@endif
<div class="bg-s1 border border-white/[.06] rounded-xl p-6">
<h3 class="text-sm font-medium text-t1 mb-5">Change Password</h3>
<div class="space-y-4 max-w-sm">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Current Password</label>
<input wire:model="currentPassword" type="password"
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">
@error('currentPassword') <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">New Password</label>
<input wire:model="newPassword" type="password"
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">
@error('newPassword') <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">Confirm New Password</label>
<input wire:model="newPasswordConfirmation" type="password"
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">
</div>
<button wire:click="changePassword"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90">
Change Password
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,65 @@
<div class="max-w-3xl">
<div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Settings</h1>
<nav class="flex gap-1 mt-4">
<a href="{{ route('w.settings.index', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Workspace</a>
<a href="{{ route('w.settings.api-tokens', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">API Tokens</a>
<a href="{{ route('w.settings.webhooks', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Webhooks</a>
</nav>
</div>
{{-- New token one-time display --}}
@if($newToken)
<div class="mb-4 p-4 rounded-xl bg-green/10 border border-green/20">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<p class="text-sm font-medium text-green mb-2">Token created copy it now, it won't be shown again</p>
<code class="block font-mono text-xs text-t1 bg-s3 px-3 py-2 rounded-lg break-all">{{ $newToken }}</code>
</div>
<button wire:click="dismissNewToken" class="text-t3 hover:text-t1 flex-shrink-0 text-sm"></button>
</div>
</div>
@endif
{{-- Create token --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-6 mb-4">
<h3 class="text-sm font-medium text-t1 mb-4">Create New Token</h3>
<div class="flex gap-3 max-w-sm">
<input wire:model="tokenName" type="text" placeholder="Token name"
class="flex-1 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">
<button wire:click="createToken"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 flex-shrink-0">
Create
</button>
</div>
@error('tokenName') <p class="mt-2 text-xs text-red">{{ $message }}</p> @enderror
</div>
{{-- Token list --}}
<div class="bg-s1 border border-white/[.06] rounded-xl overflow-hidden">
<div class="px-5 py-3 border-b border-white/[.06]">
<span class="text-xs font-medium text-t2 uppercase tracking-wider">Active Tokens</span>
</div>
<div class="divide-y divide-white/[.04]">
@forelse($tokens as $token)
<div class="flex items-center gap-4 px-5 py-4">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-t1">{{ $token->name }}</div>
<div class="text-xs text-t2 mt-0.5">
Created {{ $token->created_at->diffForHumans() }}
@if($token->last_used_at) · Last used {{ $token->last_used_at->diffForHumans() }} @endif
</div>
</div>
<button wire:click="revokeToken({{ $token->id }})"
wire:confirm="Revoke this token?"
class="text-red text-xs hover:opacity-80">Revoke</button>
</div>
@empty
<div class="px-5 py-10 text-center text-t3 text-sm">No API tokens yet</div>
@endforelse
</div>
</div>
</div>

View File

@ -1,32 +1,51 @@
<div> <div class="max-w-3xl">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Settings</h1> <h1 class="text-2xl font-semibold text-t1">Settings</h1>
<p class="text-sm text-t2 mt-1">Manage your workspace settings</p> <nav class="flex gap-1 mt-4">
<a href="{{ route('w.settings.index', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Workspace</a>
<a href="{{ route('w.settings.api-tokens', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">API Tokens</a>
<a href="{{ route('w.settings.webhooks', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Webhooks</a>
</nav>
</div> </div>
<div class="space-y-4"> @if(session('status'))
{{-- Workspace Name --}} <div class="mb-4 p-3 rounded-lg bg-green/10 border border-green/20 text-sm text-green">{{ session('status') }}</div>
<div class="bg-s1 border border-white/[.06] rounded-xl p-6"> @endif
<h3 class="text-sm font-medium text-t1 mb-4">Workspace</h3>
<div class="max-w-sm space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Name</label>
<input type="text" value="{{ app('current_workspace')?->name }}"
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">
</div>
<button class="px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90">
Save Changes
</button>
</div>
</div>
{{-- Danger Zone --}} {{-- Workspace Info --}}
<div class="bg-s1 border border-red/20 rounded-xl p-6"> <div class="bg-s1 border border-white/[.06] rounded-xl p-6 mb-4">
<h3 class="text-sm font-medium text-red mb-2">Danger Zone</h3> <h3 class="text-sm font-medium text-t1 mb-5">Workspace Settings</h3>
<p class="text-sm text-t2 mb-4">Once you delete a workspace, there is no going back.</p> <div class="space-y-4 max-w-sm">
<button class="px-4 py-2 bg-red/10 text-red text-sm font-medium rounded-lg hover:bg-red/20 border border-red/20"> <div>
Delete Workspace <label class="block text-xs font-medium text-t2 mb-1.5">Workspace Name</label>
<input wire:model="workspaceName" type="text"
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">
@error('workspaceName') <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">Workspace Slug</label>
<div class="flex items-center bg-s2 border border-white/[.06] rounded-lg overflow-hidden">
<span class="px-3 py-2.5 text-sm text-t3 border-r border-white/[.06]">nimu.li/~</span>
<input wire:model="workspaceSlug" type="text"
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-t1 font-mono focus:outline-none">
</div>
</div>
<button wire:click="saveWorkspace"
class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90">
Save Changes
</button> </button>
</div> </div>
</div> </div>
{{-- Danger Zone --}}
<div class="bg-s1 border border-red/20 rounded-xl p-6">
<h3 class="text-sm font-medium text-red mb-2">Danger Zone</h3>
<p class="text-sm text-t2 mb-4">Permanently delete this workspace and all its data. This action cannot be undone.</p>
<button class="px-4 py-2 bg-red/10 text-red text-sm font-medium rounded-lg hover:bg-red/20 border border-red/20">
Delete Workspace
</button>
</div>
</div> </div>

View File

@ -0,0 +1,30 @@
<div class="max-w-3xl">
<div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Settings</h1>
<nav class="flex gap-1 mt-4">
<a href="{{ route('w.settings.index', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">Workspace</a>
<a href="{{ route('w.settings.api-tokens', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg text-t2 hover:text-t1 hover:bg-s2">API Tokens</a>
<a href="{{ route('w.settings.webhooks', request()->route('workspace')) }}"
class="px-3 py-1.5 text-sm rounded-lg bg-blue/10 text-blue font-medium">Webhooks</a>
</nav>
</div>
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-t2">Receive HTTP POST notifications for workspace events</p>
<button class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90">
+ Add Webhook
</button>
</div>
<div class="bg-s1 border border-white/[.06] rounded-xl p-12 text-center">
<div class="w-14 h-14 bg-s2 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-7 h-7 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/>
</svg>
</div>
<h3 class="text-base font-medium text-t1 mb-2">No webhooks configured</h3>
<p class="text-sm text-t2">Add a webhook URL to receive real-time events.</p>
</div>
</div>

View File

@ -0,0 +1,44 @@
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold text-t1">Team</h1>
<p class="text-sm text-t2 mt-1">Manage workspace members and permissions</p>
</div>
<button class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90">
+ Invite Member
</button>
</div>
<div class="bg-s1 border border-white/[.06] rounded-xl overflow-hidden">
<div class="px-5 py-3 border-b border-white/[.06] flex items-center justify-between">
<span class="text-xs font-medium text-t2 uppercase tracking-wider">{{ $members->count() }} {{ Str::plural('member', $members->count()) }}</span>
</div>
<div class="divide-y divide-white/[.04]">
@forelse($members as $member)
<div class="flex items-center gap-4 px-5 py-4">
{{-- Avatar --}}
<div class="w-9 h-9 rounded-full bg-blue flex items-center justify-center text-white text-sm font-semibold flex-shrink-0">
{{ strtoupper(substr($member->user->name ?? $member->user->email, 0, 1)) }}
</div>
{{-- Info --}}
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-t1">{{ $member->user->name ?? '—' }}</div>
<div class="text-xs text-t2">{{ $member->user->email }}</div>
</div>
{{-- Role --}}
<span class="px-2.5 py-1 rounded-full text-xs font-medium
{{ $member->role === 'owner' ? 'bg-amber/10 text-amber' : ($member->role === 'admin' ? 'bg-blue/10 text-blue' : 'bg-white/[.06] text-t2') }}">
{{ ucfirst($member->role) }}
</span>
{{-- Actions --}}
@if($member->role !== 'owner')
<button class="text-t3 text-xs hover:text-red transition-colors">Remove</button>
@endif
</div>
@empty
<div class="px-5 py-12 text-center text-t3 text-sm">No team members yet</div>
@endforelse
</div>
</div>
</div>

View File

@ -22,9 +22,13 @@ Route::get('dashboard', function () {
return view('dashboard'); return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard');
Route::view('profile', 'profile') Route::get('profile', fn() => redirect()->route('profile.personal'))->middleware(['auth'])->name('profile');
->middleware(['auth'])
->name('profile'); Route::middleware(['auth'])->group(function () {
Route::get('/profile/personal', App\Livewire\Pages\Profile\PersonalInfo::class)->name('profile.personal');
Route::get('/profile/preferences', App\Livewire\Pages\Profile\Preferences::class)->name('profile.preferences');
Route::get('/profile/security', App\Livewire\Pages\Profile\Security::class)->name('profile.security');
});
// Workspace-scoped routes — placed before slug catch-all // Workspace-scoped routes — placed before slug catch-all
Route::middleware(['auth', 'verified', ResolveWorkspace::class]) Route::middleware(['auth', 'verified', ResolveWorkspace::class])
@ -38,7 +42,10 @@ Route::middleware(['auth', 'verified', ResolveWorkspace::class])
Route::get('/analytics', App\Livewire\Pages\Analytics\Index::class)->name('analytics.index'); Route::get('/analytics', App\Livewire\Pages\Analytics\Index::class)->name('analytics.index');
Route::get('/domains', App\Livewire\Pages\Domains\Index::class)->name('domains.index'); Route::get('/domains', App\Livewire\Pages\Domains\Index::class)->name('domains.index');
Route::get('/settings', App\Livewire\Pages\Settings\Index::class)->name('settings.index'); Route::get('/settings', App\Livewire\Pages\Settings\Index::class)->name('settings.index');
Route::get('/settings/api-tokens', App\Livewire\Pages\Settings\ApiTokens::class)->name('settings.api-tokens');
Route::get('/settings/webhooks', App\Livewire\Pages\Settings\Webhooks::class)->name('settings.webhooks');
Route::get('/billing', App\Livewire\Pages\Billing\Index::class)->name('billing.index'); Route::get('/billing', App\Livewire\Pages\Billing\Index::class)->name('billing.index');
Route::get('/team', App\Livewire\Pages\Team\Members::class)->name('team.index');
}); });
// Bio page route — must be before the slug catch-all // Bio page route — must be before the slug catch-all