Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
7f27041
Add organization dashboard design spec (issue #23)
rathorevaibhav Jun 25, 2026
67c9418
Add organization dashboard implementation plan (issue #23)
rathorevaibhav Jun 25, 2026
f9dfbe9
feat: add Organization model, tables, and factory
rathorevaibhav Jun 26, 2026
95a63f9
refactor: annotate organization_user role default with constant refer…
rathorevaibhav Jun 26, 2026
2864613
feat: add user-organization memberships, roles, and super-admin flag
rathorevaibhav Jun 26, 2026
689f82d
feat: add CurrentOrganization request-scoped service
rathorevaibhav Jun 26, 2026
47d4f65
test: cover super-admin and no-membership resolveFor paths
rathorevaibhav Jun 26, 2026
ccc408c
feat: add organization_id to monitors/groups with explicit scoping trait
rathorevaibhav Jun 26, 2026
fc7c7b7
feat: resolve and share the active organization per request
rathorevaibhav Jun 26, 2026
5ace702
fix: guard SetActiveOrganization against unauthenticated requests
rathorevaibhav Jun 26, 2026
81867a4
feat: scope monitor/group binding and queries to the active organization
rathorevaibhav Jun 26, 2026
f1bb878
fix: re-scope groups index eager-load and add groups isolation test
rathorevaibhav Jun 26, 2026
26cd93e
feat: enforce admin/member authorization with super-admin bypass
rathorevaibhav Jun 26, 2026
091df5a
feat: add organization switcher endpoint and nav UI
rathorevaibhav Jun 26, 2026
2d744e6
fix: single auth source in nav, super-admin switch test, tidy route i…
rathorevaibhav Jun 26, 2026
ce4909c
feat: disable open self-registration
rathorevaibhav Jun 26, 2026
3283820
feat: super-admin organization onboarding and rename
rathorevaibhav Jun 26, 2026
46550e5
feat: org-scoped user management with roles
rathorevaibhav Jun 26, 2026
3ad6d62
fix: harden user-management password guard and add IDOR/member-403 tests
rathorevaibhav Jun 26, 2026
9c3a5bc
feat: backfill default organization and require organization_id
rathorevaibhav Jun 26, 2026
2dac839
fix: harden user-request, add group authz + console-scope regression …
rathorevaibhav Jun 26, 2026
8377c37
Apply automatic changes
rathorevaibhav Jun 26, 2026
109b91a
feat: add super-admin Organizations nav link
rathorevaibhav Jul 2, 2026
d85b6c2
fix: super-admin org switcher lists all organizations
rathorevaibhav Jul 2, 2026
951579f
fix: hide admin-only UI from members (create buttons, edit/delete ico…
rathorevaibhav Jul 2, 2026
42a29e3
fix: org switcher lands on the dashboard and shows the active org eve…
rathorevaibhav Jul 2, 2026
e54b6e3
Add organization deletion design spec (soft delete + 60-day purge)
rathorevaibhav Jul 2, 2026
ea1061c
Add organization deletion implementation plan (7 TDD tasks)
rathorevaibhav Jul 2, 2026
23dfc41
feat: add SoftDeletes to Organization, User, Monitor, Group
rathorevaibhav Jul 2, 2026
a1dc3ba
feat: live-only unique indexes for monitor url and organization slug
rathorevaibhav Jul 2, 2026
8ab0de4
feat: cascade org soft delete and precise restore via OrganizationDel…
rathorevaibhav Jul 2, 2026
b187333
feat: scheduled 60-day purge of soft-deleted organizations
rathorevaibhav Jul 2, 2026
f1ec21b
feat: super-admin delete and restore for organizations
rathorevaibhav Jul 2, 2026
7f8d010
feat: restore-and-link trashed accounts by email; guard group deletion
rathorevaibhav Jul 2, 2026
2449e12
feat: soft-delete-aware monitor:delete override
rathorevaibhav Jul 2, 2026
5ad2826
fix: reword restore flash copy (skipped list holds monitor names)
rathorevaibhav Jul 2, 2026
5c09de4
fix: drop plain unique indexes name-agnostically in the functional-in…
rathorevaibhav Jul 2, 2026
a34ca8b
Apply automatic changes
rathorevaibhav Jul 2, 2026
25a8f3d
docs: credit system design spec
rathorevaibhav Jul 2, 2026
2dc9fab
docs: add performance notes to credit system spec
rathorevaibhav Jul 2, 2026
ac6b4d2
docs: add config-derived runway projection to credit system spec
rathorevaibhav Jul 2, 2026
0dfdeaf
fix: close org-admin account-takeover in user management
rathorevaibhav Jul 3, 2026
b65a0c1
fix: backfill migration must not use Eloquent before deleted_at exists
rathorevaibhav Jul 3, 2026
476753a
fix: disable unsafe vendor monitor:create / monitor:sync-file commands
rathorevaibhav Jul 3, 2026
2534cff
fix: address medium code-review findings (#4/#5/#8/#9)
rathorevaibhav Jul 3, 2026
5bad990
chore: remove credit-system spec from tenancy PR
rathorevaibhav Jul 3, 2026
80a00f5
docs: document DEFAULT_USER_EMAIL/PASSWORD in .env.example
rathorevaibhav Jul 3, 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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

# Default/bootstrap user. On the first tenancy migration this account is
# promoted to platform super-admin (the only role that can onboard or delete
# organizations). Set DEFAULT_USER_EMAIL to your real admin email BEFORE
# deploying/migrating in production. DEFAULT_USER_PASSWORD is only used when
# seeding the user locally.
DEFAULT_USER_EMAIL=user@example.com
DEFAULT_USER_PASSWORD=

UPTIME_MONITOR_MAIL_TO='' # comma separated string of emails
UPTIME_MONITOR_GOOGLE_CHAT_WEBHOOK_URL=''
MONITOR_HISTORY_ENABLED=false
Expand Down
15 changes: 15 additions & 0 deletions app/Console/Commands/CreateMonitorDisabled.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\Console\Commands;

class CreateMonitorDisabled extends DisabledMonitorCommand
{
protected $signature = 'monitor:create {url?}';

protected $description = 'Disabled in multi-tenant mode — create monitors from the dashboard';

protected function disabledMessage(): string
{
return 'monitor:create is disabled: it cannot assign an organization (organization_id is required). Create monitors from the dashboard.';
}
}
40 changes: 40 additions & 0 deletions app/Console/Commands/DeleteMonitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

/**
* Overrides spatie/laravel-uptime-monitor's monitor:delete, which hardcodes
* the base Spatie model and issues a HARD delete (destroying check history
* and bypassing the soft-delete retention window). This version resolves the
* configured monitor model, so delete() is a soft delete.
*/
class DeleteMonitor extends Command
{
protected $signature = 'monitor:delete {url}';

protected $description = 'Soft-delete a monitor (restorable until the retention purge)';

public function handle(): int
{
$modelClass = config('uptime-monitor.monitor_model');
$url = $this->argument('url');

$monitor = $modelClass::where('url', $url)->first();

if (! $monitor) {
$this->error("Monitor {$url} is not configured");

return self::FAILURE;
}

if ($this->confirm("Are you sure you want stop monitoring {$monitor->url}?")) {
$monitor->delete();

$this->warn("{$monitor->url} will not be monitored anymore");
}

return self::SUCCESS;
}
}
34 changes: 34 additions & 0 deletions app/Console/Commands/DisabledMonitorCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

/**
* Base for spatie/laravel-uptime-monitor CLI commands that are unsafe under
* multi-tenancy: they operate on the base Spatie model, so they bypass
* organization assignment and soft deletes (e.g. `monitor:sync-file
* --delete-missing` hard-deletes across all organizations and destroys check
* history). We rebind them to this refusing shim; monitors are managed through
* the dashboard, and `monitor:delete` has a soft-delete-aware override.
*/
abstract class DisabledMonitorCommand extends Command
{
public function __construct()
{
parent::__construct();

// Tolerate the vendor command's arguments/options so we can refuse
// cleanly instead of erroring on an unknown flag.
$this->ignoreValidationErrors();
}

public function handle(): int
{
$this->error($this->disabledMessage());

return self::FAILURE;
}

abstract protected function disabledMessage(): string;
}
72 changes: 72 additions & 0 deletions app/Console/Commands/PurgeDeletedOrganizations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace App\Console\Commands;

use App\Models\Group;
use App\Models\Monitor;
use App\Models\Organization;
use App\Services\OrganizationDeletionService;
use Illuminate\Console\Command;

class PurgeDeletedOrganizations extends Command
{
protected $signature = 'organizations:purge-deleted
{--older-than-days= : Override the retention period in days}
{--dry-run : List what would be purged without deleting anything}';

protected $description = 'Hard-delete organizations (and their cascaded data) soft-deleted longer than the retention period';

public function handle(OrganizationDeletionService $service): int
{
$days = max(1, (int) ($this->option('older-than-days') ?: config('organizations.purge_after_days', 60)));
$cutoff = now()->subDays($days);

$organizations = Organization::onlyTrashed()
->where('deleted_at', '<=', $cutoff)
->orderBy('deleted_at')
->get();

foreach ($organizations as $organization) {
$monitors = Monitor::withTrashed()->where('organization_id', $organization->id)->count();
$groups = Group::withTrashed()->where('organization_id', $organization->id)->count();

if ($this->option('dry-run')) {
$this->info("[dry-run] Would purge '{$organization->name}' ({$monitors} monitors, {$groups} groups).");

continue;
}

$service->purge($organization);
$this->info("Purged '{$organization->name}' ({$monitors} monitors, {$groups} groups).");
}

// Orphan pass: trashed monitors/groups of LIVE orgs (individually
// deleted, or skipped by a restore) past the same cutoff.
if ($this->option('dry-run')) {
$orphanMonitors = Monitor::onlyTrashed()->where('deleted_at', '<=', $cutoff)->whereHas('organization')->count();
$orphanGroups = Group::onlyTrashed()->where('deleted_at', '<=', $cutoff)->whereHas('organization')
->whereDoesntHave('monitors', fn ($q) => $q->withTrashed())->count();

if ($orphanMonitors > 0 || $orphanGroups > 0) {
$this->info("[dry-run] Would purge {$orphanMonitors} orphaned monitors and {$orphanGroups} orphaned groups.");
}

if ($organizations->isEmpty() && $orphanMonitors === 0 && $orphanGroups === 0) {
$this->info('Nothing to purge.');
}

return self::SUCCESS;
}

$orphans = $service->purgeOrphanedChildren($cutoff);
if ($orphans['monitors'] > 0 || $orphans['groups'] > 0) {
$this->info("Purged {$orphans['monitors']} orphaned monitors and {$orphans['groups']} orphaned groups.");
}

if ($organizations->isEmpty() && $orphans['monitors'] === 0 && $orphans['groups'] === 0) {
$this->info('Nothing to purge.');
}

return self::SUCCESS;
}
}
15 changes: 15 additions & 0 deletions app/Console/Commands/SyncFileDisabled.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\Console\Commands;

class SyncFileDisabled extends DisabledMonitorCommand
{
protected $signature = 'monitor:sync-file {file?} {--delete-missing}';

protected $description = 'Disabled in multi-tenant mode — manage monitors from the dashboard';

protected function disabledMessage(): string
{
return 'monitor:sync-file is disabled: it operates on the base model, so --delete-missing hard-deletes monitors across all organizations (destroying check history) and it cannot assign organizations. Manage monitors from the dashboard.';
}
}
7 changes: 7 additions & 0 deletions app/Exceptions/OrganizationRestoreBlockedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace App\Exceptions;

use RuntimeException;

class OrganizationRestoreBlockedException extends RuntimeException {}
5 changes: 5 additions & 0 deletions app/Http/Controllers/Auth/AuthenticatedSessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ public function store(LoginRequest $request)

$request->session()->regenerate();

$organization = $request->user()->organizations()->orderBy('name')->first();
if ($organization) {
$request->session()->put('active_organization_id', $organization->id);
}

return redirect()->intended('/monitors');
}

Expand Down
56 changes: 0 additions & 56 deletions app/Http/Controllers/Auth/RegisteredUserController.php

This file was deleted.

24 changes: 23 additions & 1 deletion app/Http/Controllers/GroupsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

use App\Http\Requests\GroupRequest;
use App\Models\Group;
use App\Support\CurrentOrganization;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;

class GroupsController extends Controller
Expand All @@ -27,7 +29,9 @@ public function __construct()
public function index()
{
return Inertia::render('Groups/Index', [
'groups' => Group::with('monitors')->orderBy('name')->get(),
'groups' => Group::forOrganization(app(CurrentOrganization::class)->id())
->with(['monitors' => fn ($q) => $q->forOrganization(app(CurrentOrganization::class)->id())])
->orderBy('name')->get(),
]);
}

Expand All @@ -38,6 +42,8 @@ public function index()
*/
public function create()
{
$this->authorize('create', Group::class);

return Inertia::render('Groups/Create', []);
}

Expand All @@ -48,6 +54,7 @@ public function create()
*/
public function store(GroupRequest $request)
{
$this->authorize('create', Group::class);
$validated = $request->validated();
Group::create([
'name' => $validated['name'],
Expand All @@ -63,6 +70,8 @@ public function store(GroupRequest $request)
*/
public function show(Group $group)
{
$this->authorize('view', $group);

return Inertia::render('Groups/Show', [
'group' => $group,
]);
Expand All @@ -75,6 +84,8 @@ public function show(Group $group)
*/
public function edit(Group $group)
{
$this->authorize('update', $group);

return Inertia::render('Groups/Edit', [
'group' => $group,
]);
Expand All @@ -87,6 +98,7 @@ public function edit(Group $group)
*/
public function update(GroupRequest $request, Group $group)
{
$this->authorize('update', $group);
$validated = $request->validated();
$currentDomainCheck = $group->domain_check_enabled;

Expand All @@ -104,6 +116,16 @@ public function update(GroupRequest $request, Group $group)
*/
public function destroy(Group $group)
{
$this->authorize('delete', $group);

// The FK no longer blocks this (soft delete is an UPDATE), so enforce
// explicitly: a group with live monitors must not disappear from under them.
if ($group->monitors()->exists()) {
throw ValidationException::withMessages([
'group' => 'This group still has monitors. Move or delete them before deleting the group.',
]);
}

$group->delete();

return redirect()->route('groups.index');
Expand Down
Loading
Loading