Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7daa933
feat: add multi-organization support
David-Crty May 5, 2026
a4c45cf
test: add organization coverage and remove dead code
David-Crty May 5, 2026
4a0b4ba
fix: address CodeRabbit review findings for multi-org
David-Crty May 5, 2026
a24e091
feat: enhance user management with organization-specific access contr…
David-Crty May 6, 2026
4d5ae23
feat: update organization handling to use org_id parameter and improv…
David-Crty May 6, 2026
e65d46a
feat: streamline organization management by updating method return ty…
David-Crty May 6, 2026
2f323a7
feat: update API documentation to reflect changes in organization ID …
David-Crty May 6, 2026
83a2be5
feat: enhance organization and user policies to support org admins in…
David-Crty May 6, 2026
ebdf7ed
feat: update organization alert component to remove dismissible option
David-Crty May 6, 2026
f01dbb1
Merge branch 'main' of github.com:David-Crty/databasement into feat/m…
David-Crty May 6, 2026
35369b8
feat: enhance organization management by updating resource counting a…
David-Crty May 6, 2026
2dcb3b3
Merge branch 'main' of github.com:David-Crty/databasement into feat/m…
David-Crty May 6, 2026
668276b
feat: enhance organization feedback and improve main organization res…
David-Crty May 6, 2026
91dfcca
Merge branch 'main' of github.com:David-Crty/databasement into feat/m…
David-Crty May 6, 2026
7903440
feat: enhance OAuth user handling and update profile management
David-Crty May 6, 2026
4aa4687
test: improve OAuth/SSO test coverage and fix stale references
David-Crty May 6, 2026
9b54e01
fix: allow OAuth users to delete account without password
David-Crty May 6, 2026
57ed234
fix: redirect to login route directly after account deletion
David-Crty May 6, 2026
8a3a85b
feat: log out users with no organization membership
David-Crty May 6, 2026
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
13 changes: 12 additions & 1 deletion app/Actions/Fortify/CreateNewUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Actions\Fortify;

use App\Models\Organization;
use App\Models\User;
use App\Services\DemoBackupService;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -46,14 +47,24 @@ public function create(array $input): User

$createDemoBackup = ! empty($input['create_demo_backup']);

// Ensure main org exists (migration creates it, but handle fresh install)
$mainOrg = Organization::firstOrCreate(
['is_main' => true],
['name' => 'Main']
);

// First user is always super_admin
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
'role' => User::ROLE_ADMIN, // First user is always admin
'super_admin' => true,
'invitation_accepted_at' => now(),
]);

// Attach to main org as admin
$user->organizations()->attach($mainOrg->id, ['role' => User::ROLE_ADMIN]);

Comment thread
David-Crty marked this conversation as resolved.
if ($createDemoBackup) {
try {
$this->demoBackupService->createDemoBackup();
Expand Down
3 changes: 3 additions & 0 deletions app/Http/Controllers/Api/V1/DatabaseServerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use App\Services\Backup\Databases\DatabaseProvider;
use App\Services\Backup\SyncBackupConfigurationsAction;
use App\Services\Backup\TriggerBackupAction;
use App\Services\CurrentOrganization;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -72,6 +73,8 @@ public function store(SaveDatabaseServerRequest $request): JsonResponse

DatabaseServer::buildExtraConfig($validated);

$validated['organization_id'] = app(CurrentOrganization::class)->id();

$server = DatabaseServer::create($validated);
$this->syncBackupConfigurations($server, $backupsPayload, $hasBackupsPayload);

Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Api/V1/VolumeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use App\Http\Resources\VolumeResource;
use App\Models\Volume;
use App\Queries\VolumeQuery;
use App\Services\CurrentOrganization;
use App\Services\VolumeConnectionTester;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
Expand Down Expand Up @@ -120,6 +121,7 @@ private function createVolume(StoreVolumeRequest $request): JsonResponse
$volumeType = VolumeType::from($validated['type']);

$validated['config'] = $volumeType->encryptSensitiveFields($validated['config']);
$validated['organization_id'] = app(CurrentOrganization::class)->id();
Comment thread
David-Crty marked this conversation as resolved.

$volume = Volume::create($validated);

Expand Down
9 changes: 7 additions & 2 deletions app/Http/Middleware/DemoModeMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Middleware;

use App\Models\Organization;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -48,17 +49,21 @@ public function handle(Request $request, Closure $next): Response

/**
* Create the demo user if it doesn't exist.
* Attaches to main org with demo role.
*/
protected function ensureDemoUserExists(): void
{
User::firstOrCreate(
$user = User::firstOrCreate(
['email' => config('app.demo_user_email')],
[
'name' => 'Demo User',
'password' => bcrypt(config('app.demo_user_password')),
'role' => User::ROLE_DEMO,
'invitation_accepted_at' => now(),
]
);

$user->organizations()->syncWithoutDetaching([
Organization::main()->id => ['role' => User::ROLE_DEMO],
]);
}
}
66 changes: 66 additions & 0 deletions app/Http/Middleware/SetCurrentOrganization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace App\Http\Middleware;

use App\Models\Agent;
use App\Models\User;
use App\Services\CurrentOrganization;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Symfony\Component\HttpFoundation\Response;

class SetCurrentOrganization
{
public function __construct(
private readonly CurrentOrganization $currentOrganization
) {}

public function handle(Request $request, Closure $next): Response
{
/** @var User|Agent|null $authenticatable */
$authenticatable = $request->user();

if ($authenticatable instanceof Agent) {
$this->currentOrganization->set($authenticatable->organization);
} elseif ($authenticatable instanceof User) {
$this->currentOrganization->reset();

if ($request->is('api/*')) {
$this->resolveApiOrganization($request, $authenticatable);
} else {
/** @var string|null $cookieOrgId */
$cookieOrgId = $request->cookie(CurrentOrganization::COOKIE_NAME);
$this->currentOrganization->resolveForUser($authenticatable, $cookieOrgId);
}

if (! $this->currentOrganization->isResolved()) {
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
session()->flash('error', __('Your account is not a member of any organization. Please contact an administrator.'));

return redirect()->route('login');
}
}

return $next($request);
}

/**
* Resolve organization for API requests.
* Aborts with 403 when the client explicitly requests an org that is invalid or inaccessible.
*/
private function resolveApiOrganization(Request $request, User $user): void
{
/** @var string|null $orgId */
$orgId = $request->query('org_id') ?? $request->header('X-Organization-Id');

$this->currentOrganization->resolveForUser($user, $orgId);

if ($orgId && (! $this->currentOrganization->isResolved() || $this->currentOrganization->id() !== $orgId)) {
abort(403, 'The requested organization is not accessible.');
}
}
}
5 changes: 5 additions & 0 deletions app/Livewire/Configuration/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public function getSsoConfig(): array
'value' => config('oauth.default_role') ?: '-',
'description' => __('Default role for new OAuth users: viewer, member, or admin.'),
],
[
'env' => 'OIDC_DEFAULT_ORGANIZATION_ID',
'value' => config('oauth.default_organization_id') ?: '-',
'description' => __('Organization ID for auto-created OIDC users (defaults to main org).'),
],
[
'env' => 'OAUTH_AUTO_LINK_BY_EMAIL',
'value' => config('oauth.auto_link_by_email') ? 'true' : 'false',
Expand Down
163 changes: 163 additions & 0 deletions app/Livewire/Configuration/Organization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

namespace App\Livewire\Configuration;

use App\Models\Organization as OrganizationModel;
use App\Models\Scopes\OrganizationScope;
use App\Traits\Toast;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;

#[Title('Configuration')]
class Organization extends Component
{
use AuthorizesRequests;
use Toast;

public bool $showCreateModal = false;

public string $newOrgName = '';

public bool $showEditModal = false;

public ?string $editingOrgId = null;

public string $editOrgName = '';

public bool $showDeleteModal = false;

public ?string $deleteOrgId = null;

public function mount(): void
{
$this->authorize('viewAny', OrganizationModel::class);
}

/**
* @return Collection<int, OrganizationModel>
*/
#[Computed]
public function organizations(): Collection
{
return OrganizationModel::withCount([
'users',
'databaseServers' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
'volumes' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
'agents' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
])
->orderByDesc('is_main')
->orderBy('name')
->get();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public function openCreateModal(): void
{
$this->newOrgName = '';
$this->showCreateModal = true;
}
Comment on lines +57 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Find and read the Organization.php Livewire component
find . -path "*/app/Livewire/Configuration/Organization.php" -type f | head -1

Repository: David-Crty/databasement

Length of output: 113


🏁 Script executed:

# Get the file size first to determine how to read it
wc -l app/Livewire/Configuration/Organization.php

Repository: David-Crty/databasement

Length of output: 115


🏁 Script executed:

# Read the full file since we need to understand the context
cat -n app/Livewire/Configuration/Organization.php

Repository: David-Crty/databasement

Length of output: 6122


🏁 Script executed:

# Search for validation patterns in Livewire components
rg "resetValidation" --type php -C 2

Repository: David-Crty/databasement

Length of output: 740


🏁 Script executed:

# Check if this is a Laravel/Livewire project by looking for config or composer
ls -la | grep -E "composer|artisan|laravel"

Repository: David-Crty/databasement

Length of output: 259


Reset the validation error bag when opening modals to prevent stale errors from previous submissions.

The openCreateModal() and openEditModal() methods reset field values but leave Livewire's validation error bag intact. If a form submission fails validation, reopening the modal displays those stale errors on the fresh form.

🩹 Small fix
     public function openCreateModal(): void
     {
+        $this->resetValidation();
         $this->newOrgName = '';
         $this->showCreateModal = true;
     }

     public function openEditModal(string $orgId): void
     {
+        $this->resetValidation();
         $org = OrganizationModel::findOrFail($orgId);

This pattern is already established elsewhere in the codebase (ConfigurationForm.php, NotificationChannelForm.php use resetValidation()). Aligns with the guideline to keep server-side state properly synchronized with the UI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Livewire/Configuration/Organization.php` around lines 57 - 61,
openCreateModal() and openEditModal() currently clear fields but leave
Livewire's validation error bag, causing stale validation messages to appear
when reopening the modal; call Livewire's resetValidation() in both methods (in
Organization::openCreateModal and Organization::openEditModal) to clear the
error bag when opening the modal so the form starts clean.


public function createOrganization(): mixed
{
$this->authorize('create', OrganizationModel::class);

$this->validate([
'newOrgName' => 'required|string|max:255|unique:organizations,name',
]);

OrganizationModel::create([
'name' => $this->newOrgName,
]);

$this->showCreateModal = false;
$this->newOrgName = '';

$this->success(__('Organization created.'));

return $this->redirect(route('configuration.organizations'), navigate: true);
}

public function openEditModal(string $orgId): void
{
$org = OrganizationModel::findOrFail($orgId);

$this->authorize('update', $org);

$this->editingOrgId = $orgId;
$this->editOrgName = $org->name;
$this->showEditModal = true;
}

public function updateOrganization(): mixed
{
$org = OrganizationModel::findOrFail($this->editingOrgId);

$this->authorize('update', $org);

$this->validate([
'editOrgName' => 'required|string|max:255|unique:organizations,name,'.$org->id,
]);

$org->update(['name' => $this->editOrgName]);

$this->showEditModal = false;
$this->editingOrgId = null;

$this->success(__('Organization updated.'));

return $this->redirect(route('configuration.organizations'), navigate: true);
}

public function confirmDelete(string $orgId): void
{
$org = OrganizationModel::withCount([
'databaseServers' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
'volumes' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
'agents' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
])->findOrFail($orgId);

$this->authorize('delete', $org);

$this->deleteOrgId = $orgId;
$this->showDeleteModal = true;
}

public function deleteOrganization(): mixed
{
$org = OrganizationModel::withCount([
'databaseServers' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
'volumes' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
'agents' => fn ($q) => $q->withoutGlobalScope(OrganizationScope::class),
])->findOrFail($this->deleteOrgId);

$this->authorize('delete', $org);

$org->delete();

$this->showDeleteModal = false;
$this->deleteOrgId = null;

$this->success(__('Organization deleted.'));

return $this->redirect(route('configuration.organizations'), navigate: true);
}

public function render(): View
{
return view('livewire.configuration.organization', [
'organizations' => $this->organizations(),
'headers' => [
['key' => 'name', 'label' => __('Name')],
['key' => 'id', 'label' => __('ID')],
['key' => 'users_count', 'label' => __('Users')],
['key' => 'database_servers_count', 'label' => __('Servers')],
['key' => 'volumes_count', 'label' => __('Volumes')],
['key' => 'agents_count', 'label' => __('Agents')],
['key' => 'actions', 'label' => '', 'class' => 'w-32'],
],
]);
}
}
2 changes: 1 addition & 1 deletion app/Livewire/Dashboard/JobStatusGrid.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class JobStatusGrid extends Component
#[Computed]
public function jobs(): Collection
{
return BackupJob::query()
return BackupJob::forCurrentOrg()
->select(['id', 'status', 'duration_ms', 'created_at'])
->with([
'snapshot:id,backup_job_id,database_name,database_server_id' => [
Expand Down
2 changes: 1 addition & 1 deletion app/Livewire/Dashboard/JobsActivityChart.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function mount(): void
$days = 14;
$startDate = Carbon::now()->subDays($days - 1)->startOfDay();

$jobs = BackupJob::where('created_at', '>=', $startDate)
$jobs = BackupJob::forCurrentOrg()->where('created_at', '>=', $startDate)
->get()
->groupBy(fn ($job) => $job->created_at->format('Y-m-d'));

Expand Down
2 changes: 1 addition & 1 deletion app/Livewire/Dashboard/LatestJobs.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function getSelectedJobProperty(): ?BackupJob

public function fetchJobs(): void
{
$query = BackupJob::query()
$query = BackupJob::forCurrentOrg()
->with([
'snapshot.databaseServer',
'restore.targetServer',
Expand Down
7 changes: 5 additions & 2 deletions app/Livewire/Dashboard/SnapshotsCard.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Jobs\VerifySnapshotFileJob;
use App\Models\Snapshot;
use App\Services\CurrentOrganization;
use App\Traits\Toast;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Cache;
Expand Down Expand Up @@ -36,7 +37,7 @@ public function refreshDashboard(): void

private function loadData(): void
{
$baseQuery = Snapshot::whereRelation('job', 'status', 'completed');
$baseQuery = Snapshot::forCurrentOrg()->whereRelation('job', 'status', 'completed');

$this->totalSnapshots = $baseQuery->count();
$this->verifiedSnapshots = (clone $baseQuery)->whereNotNull('file_verified_at')->count();
Expand All @@ -52,7 +53,9 @@ public function verifyFiles(): void
{
abort_unless(auth()->user()->isAdmin(), Response::HTTP_FORBIDDEN);

$lock = Cache::lock('verify-snapshot-files', 300);
$currentOrg = app(CurrentOrganization::class);
$lockKey = 'verify-snapshot-files:'.$currentOrg->id();
$lock = Cache::lock($lockKey, 300);

if (! $lock->get()) {
$this->warning(__('File verification is already running.'));
Expand Down
Loading