From 18599b7076d051172dbef034b6393055456ab538 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sat, 10 Jan 2026 18:20:48 +0100 Subject: [PATCH 1/7] feat: Add workspaces for users --- backend/WORKSPACE_SETUP.md | 111 +++++++++ backend/app/Enums/WorkspaceRole.php | 28 +++ .../Controllers/API/Auth/AuthController.php | 10 + .../Controllers/API/DisplayController.php | 7 +- .../app/Http/Controllers/AdminController.php | 217 ++++++++++++++++++ .../Http/Controllers/DisplayController.php | 44 +++- .../Http/Controllers/WorkspaceController.php | 191 +++++++++++++++ backend/app/Models/Calendar.php | 6 + backend/app/Models/Device.php | 6 + backend/app/Models/Display.php | 6 + backend/app/Models/Room.php | 6 + backend/app/Models/User.php | 61 +++++ backend/app/Models/Workspace.php | 118 ++++++++++ backend/app/Models/WorkspaceMember.php | 43 ++++ backend/app/Policies/DisplayPolicy.php | 21 +- backend/app/Services/DisplayService.php | 47 +++- ...5_12_30_000000_create_workspaces_table.php | 29 +++ ..._000001_create_workspace_members_table.php | 34 +++ ...2_30_000002_add_workspace_id_to_tables.php | 61 +++++ ...3_create_workspaces_for_existing_users.php | 68 ++++++ backend/docs/WORKSPACE_SETUP.md | 111 +++++++++ backend/resources/views/pages/admin.blade.php | 111 ++++++++- .../views/pages/admin/user.blade.php | 208 +++++++++++++++++ .../resources/views/pages/dashboard.blade.php | 3 +- backend/routes/web.php | 2 + 25 files changed, 1529 insertions(+), 20 deletions(-) create mode 100644 backend/WORKSPACE_SETUP.md create mode 100644 backend/app/Enums/WorkspaceRole.php create mode 100644 backend/app/Http/Controllers/WorkspaceController.php create mode 100644 backend/app/Models/Workspace.php create mode 100644 backend/app/Models/WorkspaceMember.php create mode 100644 backend/database/migrations/2025_12_30_000000_create_workspaces_table.php create mode 100644 backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php create mode 100644 backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php create mode 100644 backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php create mode 100644 backend/docs/WORKSPACE_SETUP.md create mode 100644 backend/resources/views/pages/admin/user.blade.php diff --git a/backend/WORKSPACE_SETUP.md b/backend/WORKSPACE_SETUP.md new file mode 100644 index 0000000..5b9e354 --- /dev/null +++ b/backend/WORKSPACE_SETUP.md @@ -0,0 +1,111 @@ +# Workspace System Documentation + +## Overview + +The workspace system allows multiple users to collaborate on managing displays, devices, calendars, and rooms. Each user automatically gets their own workspace, and Pro users can invite colleagues to join their workspace. + +## Architecture + +### Models + +1. **Workspace** - Represents a team/workspace + - Has an `owner` (User) + - Has many `members` (Users with roles) + - Contains displays, devices, calendars, rooms + +2. **WorkspaceMember** - Pivot table linking users to workspaces + - Roles: `owner`, `admin`, `member` + - `owner` role is implicit for the workspace owner + +### Relationships + +- **User** → **Workspace** (one-to-many: owned workspaces) +- **User** ↔ **Workspace** (many-to-many: member workspaces) +- **Workspace** → **Display** (one-to-many) +- **Workspace** → **Device** (one-to-many) +- **Workspace** → **Calendar** (one-to-many) +- **Workspace** → **Room** (one-to-many) + +## Migration Strategy + +1. **Existing Users**: Each user automatically gets a workspace created with their name +2. **Existing Data**: All displays, devices, calendars, and rooms are migrated to the user's workspace +3. **Backward Compatibility**: The `user_id` field is kept for backward compatibility + +## Permissions + +### Workspace Roles + +- **Owner**: Full control (can delete workspace, manage all members) +- **Admin**: Can manage members and workspace settings +- **Member**: Can view and use workspace resources + +### Display Access + +- Users can access displays they own directly (`user_id`) +- Users can access displays in workspaces they're members of (`workspace_id`) +- Device authentication checks workspace membership + +## Usage + +### Adding a Colleague + +1. Navigate to workspace settings (requires Pro) +2. Enter colleague's email address +3. Select role (admin or member) +4. Colleague receives access to all workspace resources + +### Managing Members + +- **Add Member**: Only owners/admins can add members +- **Update Role**: Change member role between admin/member +- **Remove Member**: Remove access from workspace + +## API Changes + +### DisplayController + +- `index()` now returns displays from user's workspace(s) +- Access checks include workspace membership + +### DisplayService + +- `validateDisplayPermission()` checks workspace membership +- Pro features check workspace owner's Pro status + +## Frontend Changes Needed + +1. **Workspace Management UI** + - List workspaces + - View workspace members + - Add/remove members + - Update member roles + +2. **Display Creation** + - Automatically assign to user's primary workspace + - Allow selecting workspace (if user has multiple) + +3. **Device Connection** + - Connect code should work with workspace + - Devices inherit workspace from user + +## Migration Commands + +Run migrations in order: + +```bash +php artisan migrate +``` + +The migration `2025_12_30_000003_create_workspaces_for_existing_users.php` will: +1. Create a workspace for each existing user +2. Migrate all user's displays, devices, calendars, and rooms to their workspace +3. Add the user as an owner member + +## Notes + +- Pro subscription is required to add team members +- Workspace owner cannot be removed +- All existing functionality remains backward compatible +- `user_id` fields are kept for direct ownership tracking + diff --git a/backend/app/Enums/WorkspaceRole.php b/backend/app/Enums/WorkspaceRole.php new file mode 100644 index 0000000..35a8e3c --- /dev/null +++ b/backend/app/Enums/WorkspaceRole.php @@ -0,0 +1,28 @@ + 'Owner', + self::ADMIN => 'Admin', + self::MEMBER => 'Member', + }; + } + + /** + * Check if this role can manage the workspace + */ + public function canManage(): bool + { + return in_array($this, [self::OWNER, self::ADMIN]); + } +} + diff --git a/backend/app/Http/Controllers/API/Auth/AuthController.php b/backend/app/Http/Controllers/API/Auth/AuthController.php index 9bb5dc8..44fc6a0 100644 --- a/backend/app/Http/Controllers/API/Auth/AuthController.php +++ b/backend/app/Http/Controllers/API/Auth/AuthController.php @@ -6,6 +6,7 @@ use App\Http\Requests\API\Auth\LoginRequest; use App\Http\Resources\API\DeviceResource; use App\Models\Device; +use App\Models\User; use App\Services\OutlookService; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; @@ -32,15 +33,24 @@ public function login(LoginRequest $request): JsonResponse // Check if the code is a valid connect code if ($connectedUserId !== null) { + $user = User::find($connectedUserId); + $workspace = $user?->primaryWorkspace(); + $device = Device::firstOrCreate([ 'user_id' => $connectedUserId, 'uid' => $uid, ],[ 'user_id' => $connectedUserId, + 'workspace_id' => $workspace?->id, 'uid' => $uid, 'name' => $name, ]); + // Update workspace_id if device already existed but didn't have one + if ($device->workspace_id === null && $workspace) { + $device->update(['workspace_id' => $workspace->id]); + } + logger()->info('Device authentication successful', [ 'user_id' => $connectedUserId, 'device_id' => $device->id, diff --git a/backend/app/Http/Controllers/API/DisplayController.php b/backend/app/Http/Controllers/API/DisplayController.php index 4bc108e..6a2eda9 100644 --- a/backend/app/Http/Controllers/API/DisplayController.php +++ b/backend/app/Http/Controllers/API/DisplayController.php @@ -30,8 +30,13 @@ public function index(): JsonResponse /** @var Device $device */ $device = auth()->user(); + if (!$device->workspace_id) { + return $this->success(data: []); + } + + // Get displays from device's workspace $displays = Display::query() - ->where('user_id', $device->user_id) + ->where('workspace_id', $device->workspace_id) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->with('settings') ->get(); diff --git a/backend/app/Http/Controllers/AdminController.php b/backend/app/Http/Controllers/AdminController.php index 5c48485..2a222ba 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -6,9 +6,13 @@ use App\Models\Display; use App\Models\Instance; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMember; use Illuminate\Http\Request; +use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; class AdminController extends Controller @@ -110,10 +114,17 @@ public function index() return $user; }); + // All users for the users overview tab + $allUsers = User::query() + ->withCount('displays') + ->orderBy('created_at', 'desc') + ->get(); + return view('pages.admin', [ 'activeInstances' => $activeInstances, 'activeDisplays' => $activeDisplays, 'payingUsers' => $payingUsers, + 'allUsers' => $allUsers, 'activeDisplaysCount' => $activeDisplays->count(), 'totalDisplays' => $totalDisplays, 'activeInstancesCount' => $activeInstances->count(), @@ -313,4 +324,210 @@ private function getSubscriptionPrice(string $subscriptionId, int $displaysCount return null; } } + + /** + * Show user details page + */ + public function showUser(User $user) + { + $admin = Auth::user(); + if (!$admin || !$admin->isAdmin() || config('settings.is_self_hosted')) { + abort(403); + } + + // Load user relationships for display + $user->load([ + 'outlookAccounts', + 'googleAccounts', + 'caldavAccounts', + 'displays', + 'devices', + 'workspaces', + 'subscriptions' => function($query) { + $query->where(function($q) { + $q->whereNull('ends_at') + ->orWhere('ends_at', '>', now()); + })->orderByDesc('created_at'); + }, + ]); + + // Get subscription info + $subscriptionInfo = null; + if (!$user->is_unlimited && $user->subscriptions->isNotEmpty()) { + $subscription = $user->subscriptions->first(); + $subscriptionData = $this->getSubscriptionData($subscription->lemon_squeezy_id, $user->displays_count); + if ($subscriptionData) { + $subscriptionInfo = [ + 'status' => $subscriptionData['status'] ?? null, + 'price' => $subscriptionData['price'] ?? 0, + 'mrr' => ($subscriptionData['price'] ?? 0) * $user->displays_count, + 'ends_at' => $subscription->ends_at, + ]; + } + } + + return view('pages.admin.user', [ + 'user' => $user, + 'subscriptionInfo' => $subscriptionInfo, + ]); + } + + /** + * Delete a user account and all associated data + */ + public function deleteUser(Request $request, User $user): RedirectResponse + { + $admin = Auth::user(); + if (!$admin || !$admin->isAdmin() || config('settings.is_self_hosted')) { + abort(403); + } + + // Prevent deleting yourself + if ($user->id === $admin->id) { + return redirect()->route('admin.index') + ->with('error', 'You cannot delete your own account.'); + } + + // Confirm deletion + $request->validate([ + 'confirm_email' => ['required', 'email'], + ]); + + if ($request->input('confirm_email') !== $user->email) { + return back()->withErrors(['confirm_email' => 'Email confirmation does not match.']); + } + + DB::transaction(function () use ($user, $admin) { + // Delete all user's personal access tokens + $user->tokens()->delete(); + + // Delete displays and their related data first (before calendars/accounts) + if ($user->displays) { + foreach ($user->displays as $display) { + // Delete event subscriptions + $display->eventSubscriptions()->delete(); + // Delete display settings + $display->settings()->delete(); + // Delete events associated with this display + $display->events()->delete(); + // Delete devices associated with this display + $display->devices()->delete(); + $display->delete(); + } + } + + // Delete devices (standalone devices not linked to displays) + $user->devices()->delete(); + + // Delete rooms + $user->rooms()->delete(); + + // Delete Outlook accounts and their calendars/events + if ($user->outlookAccounts) { + foreach ($user->outlookAccounts as $account) { + if ($account->calendars) { + foreach ($account->calendars as $calendar) { + $calendar->events()->delete(); + $calendar->delete(); + } + } + $account->delete(); + } + } + + // Delete Google accounts and their calendars/events + if ($user->googleAccounts) { + foreach ($user->googleAccounts as $account) { + if ($account->calendars) { + foreach ($account->calendars as $calendar) { + $calendar->events()->delete(); + $calendar->delete(); + } + } + $account->delete(); + } + } + + // Delete CalDAV accounts and their calendars/events + if ($user->caldavAccounts) { + foreach ($user->caldavAccounts as $account) { + if ($account->calendars) { + foreach ($account->calendars as $calendar) { + $calendar->events()->delete(); + $calendar->delete(); + } + } + $account->delete(); + } + } + + // Delete any remaining calendars directly linked to user (shouldn't happen, but safety check) + // Note: Calendars are linked through accounts, not directly to users, so this is unlikely + // Events are deleted through calendars above + + // Handle workspaces + $ownedWorkspaces = $user->ownedWorkspaces()->get(); + foreach ($ownedWorkspaces as $workspace) { + // Get other members (excluding the user being deleted) + $otherMembers = $workspace->members()->where('user_id', '!=', $user->id)->get(); + + if ($otherMembers->isNotEmpty()) { + // Find first admin or first member to transfer ownership + $newOwner = $otherMembers->first(function ($member) { + return $member->pivot->role === \App\Enums\WorkspaceRole::ADMIN->value; + }) ?? $otherMembers->first(); + + if ($newOwner) { + // Transfer ownership + WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $newOwner->id) + ->update(['role' => \App\Enums\WorkspaceRole::OWNER]); + } + } else { + // No other members, delete the workspace and all its data + foreach ($workspace->displays as $display) { + $display->eventSubscriptions()->delete(); + $display->settings()->delete(); + $display->events()->delete(); + $display->devices()->delete(); + $display->delete(); + } + $workspace->devices()->delete(); + foreach ($workspace->calendars as $calendar) { + $calendar->events()->delete(); + $calendar->delete(); + } + $workspace->rooms()->delete(); + WorkspaceMember::where('workspace_id', $workspace->id)->delete(); + $workspace->delete(); + } + } + + // Delete workspace memberships (user's membership in workspaces they don't own) + WorkspaceMember::where('user_id', $user->id)->delete(); + + // Delete instance if exists + Instance::where('user_id', $user->id)->delete(); + + // Cancel LemonSqueezy subscriptions (if any) + // Note: This doesn't actually cancel them in LemonSqueezy, just removes the local reference + // You might want to add API call to cancel subscriptions + if (method_exists($user, 'subscriptions')) { + $user->subscriptions()->delete(); + } + + // Finally, delete the user + $user->delete(); + + logger()->info('User account deleted by admin', [ + 'deleted_user_id' => $user->id, + 'deleted_user_email' => $user->email, + 'deleted_by_admin_id' => $admin->id, + 'deleted_by_admin_email' => $admin->email, + ]); + }); + + return redirect()->route('admin.index') + ->with('success', "User account {$user->email} and all associated data have been permanently deleted."); + } } diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index 090888f..c996dba 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -68,12 +68,20 @@ public function store(CreateDisplayRequest $request): RedirectResponse default => throw new \InvalidArgumentException('Invalid provider') }; - $display = DB::transaction(function () use ($validatedData) { + $user = auth()->user(); + $workspace = $user->primaryWorkspace(); + + if (!$workspace) { + return redirect()->back()->with('error', 'No workspace found. Please contact support.'); + } + + $display = DB::transaction(function () use ($validatedData, $workspace) { // Handle room or calendar selection - $calendar = $this->createCalendar($validatedData); + $calendar = $this->createCalendar($validatedData, $workspace); return Display::create([ 'user_id' => auth()->id(), + 'workspace_id' => $workspace->id, 'name' => $validatedData['name'], 'display_name' => $validatedData['displayName'], 'status' => DisplayStatus::READY, @@ -127,10 +135,11 @@ public function delete(Display $display): RedirectResponse ->with('status', 'Display has successfully been deleted.'); } - private function createCalendar(array $validatedData): Calendar + private function createCalendar(array $validatedData, $workspace): Calendar { $provider = $validatedData['provider']; $accountId = $validatedData['account']; + $userId = auth()->id(); if (isset($validatedData['room'])) { $roomData = explode(',', $validatedData['room']); @@ -139,20 +148,27 @@ private function createCalendar(array $validatedData): Calendar $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, ], [ 'calendar_id' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'name' => $calendarName, ]); + // Update workspace_id if calendar already existed + if (!$calendar->workspace_id) { + $calendar->update(['workspace_id' => $workspace->id]); + } + Room::firstOrCreate([ 'email_address' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, ], [ 'email_address' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, 'calendar_id' => $calendar->id, 'name' => $calendarName, ]); @@ -163,15 +179,23 @@ private function createCalendar(array $validatedData): Calendar $calendarData = explode(',', $validatedData['calendar']); $calendarName = $this->extractCalendarName($calendarData[1] ?? ''); - return Calendar::firstOrCreate([ + $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarData[0], - 'user_id' => auth()->id(), + 'user_id' => $userId, ], [ - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'calendar_id' => $calendarData[0], 'name' => $calendarName, ]); + + // Update workspace_id if calendar already existed + if (!$calendar->workspace_id) { + $calendar->update(['workspace_id' => $workspace->id]); + } + + return $calendar; } /** diff --git a/backend/app/Http/Controllers/WorkspaceController.php b/backend/app/Http/Controllers/WorkspaceController.php new file mode 100644 index 0000000..aeecd92 --- /dev/null +++ b/backend/app/Http/Controllers/WorkspaceController.php @@ -0,0 +1,191 @@ +user(); + $workspaces = $user->accessibleWorkspaces(); + $primaryWorkspace = $user->primaryWorkspace(); + + return view('pages.workspaces.index', [ + 'workspaces' => $workspaces, + 'primaryWorkspace' => $primaryWorkspace, + ]); + } + + /** + * Show workspace members page + */ + public function show(Workspace $workspace) + { + $user = auth()->user(); + + // Check if user has access to this workspace + if (!$workspace->hasMember($user)) { + abort(403, 'You do not have access to this workspace'); + } + + $workspace->load('members'); + + return view('pages.workspaces.show', [ + 'workspace' => $workspace, + 'userRole' => $workspace->getUserRole($user), + ]); + } + + /** + * Add a member to the workspace + */ + public function addMember(Request $request, Workspace $workspace): RedirectResponse + { + $user = auth()->user(); + + // Only workspace owners/admins can add members + if (!$workspace->canBeManagedBy($user)) { + abort(403, 'You do not have permission to add members to this workspace'); + } + + // Check if user has Pro (required for team features) + if (!$user->hasPro()) { + return back()->withErrors(['error' => 'Pro subscription is required to add team members']); + } + + $validated = $request->validate([ + 'email' => ['required', 'email', 'exists:users,email'], + 'role' => ['required', Rule::in([WorkspaceRole::ADMIN->value, WorkspaceRole::MEMBER->value])], + ]); + + $memberUser = User::where('email', $validated['email'])->first(); + + // Check if user is already a member + if ($workspace->hasMember($memberUser)) { + return back()->withErrors(['email' => 'User is already a member of this workspace']); + } + + // Add member (use WorkspaceMember::create to generate ULID) + $role = WorkspaceRole::from($validated['role']); + WorkspaceMember::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $memberUser->id, + 'role' => $role, + ]); + + logger()->info('Workspace member added', [ + 'workspace_id' => $workspace->id, + 'added_by' => $user->id, + 'member_id' => $memberUser->id, + 'role' => $validated['role'], + ]); + + return back()->with('success', 'Member added successfully'); + } + + /** + * Update a member's role + */ + public function updateMemberRole(Request $request, Workspace $workspace, User $member): RedirectResponse + { + $user = auth()->user(); + + // Only workspace owners/admins can update roles + if (!$workspace->canBeManagedBy($user)) { + abort(403, 'You do not have permission to update member roles'); + } + + // Cannot change owner's role + $memberRole = $workspace->getUserRole($member); + if ($memberRole === WorkspaceRole::OWNER) { + return back()->withErrors(['error' => 'Cannot change the owner\'s role']); + } + + $validated = $request->validate([ + 'role' => ['required', Rule::in([WorkspaceRole::ADMIN->value, WorkspaceRole::MEMBER->value])], + ]); + + $role = WorkspaceRole::from($validated['role']); + WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $member->id) + ->update(['role' => $role]); + + logger()->info('Workspace member role updated', [ + 'workspace_id' => $workspace->id, + 'updated_by' => $user->id, + 'member_id' => $member->id, + 'new_role' => $validated['role'], + ]); + + return back()->with('success', 'Member role updated successfully'); + } + + /** + * Remove a member from the workspace + */ + public function removeMember(Workspace $workspace, User $member): RedirectResponse + { + $user = auth()->user(); + + // Only workspace owners/admins can remove members + if (!$workspace->canBeManagedBy($user)) { + abort(403, 'You do not have permission to remove members from this workspace'); + } + + // Cannot remove owner + $memberRole = $workspace->getUserRole($member); + if ($memberRole === WorkspaceRole::OWNER) { + return back()->withErrors(['error' => 'Cannot remove the workspace owner']); + } + + $workspace->members()->detach($member->id); + + logger()->info('Workspace member removed', [ + 'workspace_id' => $workspace->id, + 'removed_by' => $user->id, + 'member_id' => $member->id, + ]); + + return back()->with('success', 'Member removed successfully'); + } + + /** + * Update workspace name + */ + public function update(Request $request, Workspace $workspace): RedirectResponse + { + $user = auth()->user(); + + // Only workspace owners/admins can update workspace + if (!$workspace->canBeManagedBy($user)) { + abort(403, 'You do not have permission to update this workspace'); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $workspace->update(['name' => $validated['name']]); + + logger()->info('Workspace updated', [ + 'workspace_id' => $workspace->id, + 'updated_by' => $user->id, + 'new_name' => $validated['name'], + ]); + + return back()->with('success', 'Workspace updated successfully'); + } +} + diff --git a/backend/app/Models/Calendar.php b/backend/app/Models/Calendar.php index ee3f88f..0acf46d 100644 --- a/backend/app/Models/Calendar.php +++ b/backend/app/Models/Calendar.php @@ -17,6 +17,7 @@ class Calendar extends Model protected $fillable = [ 'user_id', + 'workspace_id', 'outlook_account_id', 'google_account_id', 'caldav_account_id', @@ -54,4 +55,9 @@ public function events(): HasMany { return $this->hasMany(Event::class); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } } diff --git a/backend/app/Models/Device.php b/backend/app/Models/Device.php index 068ef8a..3c47216 100644 --- a/backend/app/Models/Device.php +++ b/backend/app/Models/Device.php @@ -21,6 +21,7 @@ class Device extends Model implements Authenticatable protected $fillable = [ 'user_id', + 'workspace_id', 'display_id', 'name', 'uid', @@ -40,4 +41,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } } diff --git a/backend/app/Models/Display.php b/backend/app/Models/Display.php index 099ab7c..92f446c 100644 --- a/backend/app/Models/Display.php +++ b/backend/app/Models/Display.php @@ -18,6 +18,7 @@ class Display extends Model protected $fillable = [ 'user_id', + 'workspace_id', 'name', 'display_name', 'calendar_id', @@ -42,6 +43,11 @@ public function user(): BelongsTo return $this->belongsTo(User::class, 'user_id'); } + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } + public function eventSubscriptions(): HasMany { return $this->hasMany(EventSubscription::class); diff --git a/backend/app/Models/Room.php b/backend/app/Models/Room.php index 097d5b8..1762d23 100644 --- a/backend/app/Models/Room.php +++ b/backend/app/Models/Room.php @@ -17,6 +17,7 @@ class Room extends Model protected $fillable = [ 'user_id', + 'workspace_id', 'calendar_id', 'name', 'email_address', @@ -31,4 +32,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 63f50bf..ff328a0 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -4,10 +4,12 @@ use App\Enums\Plan; use App\Enums\UsageType; +use App\Enums\WorkspaceRole; use App\Traits\HasUlid; use App\Traits\HasLastActivity; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -19,6 +21,31 @@ class User extends Authenticatable { use HasApiTokens, HasFactory, Notifiable, HasUlid, HasLastActivity, Billable; + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + // Auto-create workspace when user is created + static::created(function ($user) { + // Only create if user doesn't already have a workspace + if (!$user->workspaces()->exists()) { + $workspace = Workspace::create([ + 'name' => $user->name . "'s Workspace", + ]); + + // Add user as owner member (use WorkspaceMember::create to generate ULID) + WorkspaceMember::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'role' => WorkspaceRole::OWNER, + ]); + } + }); + } + /** * The attributes that are mass assignable. * @@ -96,6 +123,40 @@ public function rooms(): HasMany return $this->hasMany(Room::class); } + /** + * Get workspaces owned by this user (where user has 'owner' role) + */ + public function ownedWorkspaces() + { + return $this->workspaces()->wherePivot('role', WorkspaceRole::OWNER->value); + } + + /** + * Get workspaces this user is a member of + */ + public function workspaces(): BelongsToMany + { + return $this->belongsToMany(Workspace::class, 'workspace_members') + ->withPivot('role') + ->withTimestamps(); + } + + /** + * Get the primary workspace for this user (first workspace where user is owner) + */ + public function primaryWorkspace(): ?Workspace + { + return $this->ownedWorkspaces()->first() ?? $this->workspaces()->first(); + } + + /** + * Get all workspaces this user has access to + */ + public function accessibleWorkspaces() + { + return $this->workspaces()->get(); + } + public function hasAnyDisplay(): bool { return $this->displays()->count() > 0; diff --git a/backend/app/Models/Workspace.php b/backend/app/Models/Workspace.php new file mode 100644 index 0000000..d3de3bf --- /dev/null +++ b/backend/app/Models/Workspace.php @@ -0,0 +1,118 @@ +belongsToMany(User::class, 'workspace_members') + ->withPivot('role') + ->withTimestamps(); + } + + /** + * Get all displays in this workspace + */ + public function displays(): HasMany + { + return $this->hasMany(Display::class); + } + + /** + * Get all devices in this workspace + */ + public function devices(): HasMany + { + return $this->hasMany(Device::class); + } + + /** + * Get all calendars in this workspace + */ + public function calendars(): HasMany + { + return $this->hasMany(Calendar::class); + } + + /** + * Get all rooms in this workspace + */ + public function rooms(): HasMany + { + return $this->hasMany(Room::class); + } + + /** + * Check if a user is a member of this workspace + */ + public function hasMember(User $user): bool + { + return $this->members()->where('user_id', $user->id)->exists(); + } + + /** + * Get the owner(s) of the workspace (members with 'owner' role) + */ + public function owners() + { + return $this->members()->wherePivot('role', WorkspaceRole::OWNER->value); + } + + /** + * Check if a user is the owner of this workspace + */ + public function isOwnedBy(User $user): bool + { + return $this->members()->where('user_id', $user->id)->wherePivot('role', WorkspaceRole::OWNER->value)->exists(); + } + + /** + * Check if a user can manage this workspace (owner or admin) + */ + public function canBeManagedBy(User $user): bool + { + $member = $this->members()->where('user_id', $user->id)->first(); + if (!$member) { + return false; + } + + $role = $member->pivot->role instanceof WorkspaceRole + ? $member->pivot->role + : WorkspaceRole::from($member->pivot->role); + + return $role->canManage(); + } + + /** + * Get the role of a user in this workspace + */ + public function getUserRole(User $user): ?WorkspaceRole + { + $member = $this->members()->where('user_id', $user->id)->first(); + if (!$member) { + return null; + } + + $role = $member->pivot->role; + return $role instanceof WorkspaceRole ? $role : WorkspaceRole::from($role); + } +} + diff --git a/backend/app/Models/WorkspaceMember.php b/backend/app/Models/WorkspaceMember.php new file mode 100644 index 0000000..e7e66bc --- /dev/null +++ b/backend/app/Models/WorkspaceMember.php @@ -0,0 +1,43 @@ + WorkspaceRole::class, + ]; + + /** + * Get the workspace this member belongs to + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get the user member + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} + diff --git a/backend/app/Policies/DisplayPolicy.php b/backend/app/Policies/DisplayPolicy.php index 1697a71..b7b1ca3 100644 --- a/backend/app/Policies/DisplayPolicy.php +++ b/backend/app/Policies/DisplayPolicy.php @@ -24,7 +24,12 @@ public function create(User $user): bool */ public function update(User $user, Display $display): bool { - return $user->id === $display->user_id; + if (!$display->workspace_id) { + return false; + } + + $workspace = $display->workspace; + return $workspace && $workspace->canBeManagedBy($user); } /** @@ -32,7 +37,12 @@ public function update(User $user, Display $display): bool */ public function delete(User $user, Display $display): bool { - return $user->id === $display->user_id; + if (!$display->workspace_id) { + return false; + } + + $workspace = $display->workspace; + return $workspace && $workspace->canBeManagedBy($user); } /** @@ -42,7 +52,12 @@ public function view($user, Display $display): bool { // Handle User model if ($user instanceof User) { - return $user->id === $display->user_id; + if (!$display->workspace_id) { + return false; + } + + $workspace = $display->workspace; + return $workspace && $workspace->hasMember($user); } // Handle Device model diff --git a/backend/app/Services/DisplayService.php b/backend/app/Services/DisplayService.php index 8674277..22fdf7f 100644 --- a/backend/app/Services/DisplayService.php +++ b/backend/app/Services/DisplayService.php @@ -23,8 +23,29 @@ public function getDisplay(string $displayId) */ public function validateDisplayPermission(?string $displayId, string $deviceId, array $options = []): PermissionResult { - $userId = Device::query()->where('id', $deviceId)->value('user_id'); - $display = $displayId ? Display::with('user')->where('user_id', $userId)->find($displayId) : null; + $device = Device::query()->find($deviceId); + if (!$device) { + return new PermissionResult(false, 'Device not found', 404); + } + + if (!$device->workspace_id) { + return new PermissionResult(false, 'Device not associated with a workspace', 404); + } + + $workspaceId = $device->workspace_id; + + // Find display by ID, checking workspace access + $display = null; + if ($displayId) { + $display = Display::with('workspace.members')->find($displayId); + + if ($display) { + // Check access: device and display must be in the same workspace + if (!$display->workspace_id || $display->workspace_id !== $workspaceId) { + return new PermissionResult(false, 'Display not found', 404); + } + } + } if (!$display) { return new PermissionResult(false, 'Display not found', 404); @@ -32,9 +53,27 @@ public function validateDisplayPermission(?string $displayId, string $deviceId, if ($display->isDeactivated()) { return new PermissionResult(false, 'Display is deactivated', 400); } - if (!empty($options['pro']) && (!$display->user || !$display->user->hasPro())) { - return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); + + // Check Pro feature - check workspace owners + if (!empty($options['pro'])) { + $workspace = $display->workspace; + $hasPro = false; + + if ($workspace) { + $owners = $workspace->owners()->get(); + foreach ($owners as $owner) { + if ($owner->hasPro()) { + $hasPro = true; + break; + } + } + } + + if (!$hasPro) { + return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); + } } + if (!empty($options['booking']) && !$display->isBookingEnabled()) { return new PermissionResult(false, 'Booking is not enabled for this display', 403); } diff --git a/backend/database/migrations/2025_12_30_000000_create_workspaces_table.php b/backend/database/migrations/2025_12_30_000000_create_workspaces_table.php new file mode 100644 index 0000000..423c935 --- /dev/null +++ b/backend/database/migrations/2025_12_30_000000_create_workspaces_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspaces'); + } +}; + diff --git a/backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php b/backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php new file mode 100644 index 0000000..0912dde --- /dev/null +++ b/backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php @@ -0,0 +1,34 @@ +ulid('id')->primary(); + $table->foreignUlid('workspace_id')->constrained()->onDelete('cascade'); + $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); + $table->string('role')->default('member'); // Uses WorkspaceRole enum: 'owner', 'admin', 'member' + $table->timestamps(); + + // Ensure a user can only be a member once per workspace + $table->unique(['workspace_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_members'); + } +}; + diff --git a/backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php b/backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php new file mode 100644 index 0000000..345113f --- /dev/null +++ b/backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php @@ -0,0 +1,61 @@ +foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to devices + Schema::table('devices', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to calendars + Schema::table('calendars', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to rooms + Schema::table('rooms', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('displays', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('calendars', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('rooms', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; + diff --git a/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php b/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php new file mode 100644 index 0000000..bdcf194 --- /dev/null +++ b/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php @@ -0,0 +1,68 @@ +workspaces()->exists()) { + continue; + } + + // Create workspace for user + $workspace = Workspace::create([ + 'name' => $user->name . "'s Workspace", + ]); + + // Add user as owner member (use WorkspaceMember::create to generate ULID) + WorkspaceMember::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'role' => WorkspaceRole::OWNER, + ]); + + // Migrate displays to workspace + Display::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + + // Migrate devices to workspace + Device::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + + // Migrate calendars to workspace + Calendar::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + + // Migrate rooms to workspace + Room::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // This migration cannot be fully reversed as we don't know which workspace + // data belongs to which user after potential member additions. + // In practice, you'd need to keep the user_id relationships intact. + } +}; + diff --git a/backend/docs/WORKSPACE_SETUP.md b/backend/docs/WORKSPACE_SETUP.md new file mode 100644 index 0000000..5b9e354 --- /dev/null +++ b/backend/docs/WORKSPACE_SETUP.md @@ -0,0 +1,111 @@ +# Workspace System Documentation + +## Overview + +The workspace system allows multiple users to collaborate on managing displays, devices, calendars, and rooms. Each user automatically gets their own workspace, and Pro users can invite colleagues to join their workspace. + +## Architecture + +### Models + +1. **Workspace** - Represents a team/workspace + - Has an `owner` (User) + - Has many `members` (Users with roles) + - Contains displays, devices, calendars, rooms + +2. **WorkspaceMember** - Pivot table linking users to workspaces + - Roles: `owner`, `admin`, `member` + - `owner` role is implicit for the workspace owner + +### Relationships + +- **User** → **Workspace** (one-to-many: owned workspaces) +- **User** ↔ **Workspace** (many-to-many: member workspaces) +- **Workspace** → **Display** (one-to-many) +- **Workspace** → **Device** (one-to-many) +- **Workspace** → **Calendar** (one-to-many) +- **Workspace** → **Room** (one-to-many) + +## Migration Strategy + +1. **Existing Users**: Each user automatically gets a workspace created with their name +2. **Existing Data**: All displays, devices, calendars, and rooms are migrated to the user's workspace +3. **Backward Compatibility**: The `user_id` field is kept for backward compatibility + +## Permissions + +### Workspace Roles + +- **Owner**: Full control (can delete workspace, manage all members) +- **Admin**: Can manage members and workspace settings +- **Member**: Can view and use workspace resources + +### Display Access + +- Users can access displays they own directly (`user_id`) +- Users can access displays in workspaces they're members of (`workspace_id`) +- Device authentication checks workspace membership + +## Usage + +### Adding a Colleague + +1. Navigate to workspace settings (requires Pro) +2. Enter colleague's email address +3. Select role (admin or member) +4. Colleague receives access to all workspace resources + +### Managing Members + +- **Add Member**: Only owners/admins can add members +- **Update Role**: Change member role between admin/member +- **Remove Member**: Remove access from workspace + +## API Changes + +### DisplayController + +- `index()` now returns displays from user's workspace(s) +- Access checks include workspace membership + +### DisplayService + +- `validateDisplayPermission()` checks workspace membership +- Pro features check workspace owner's Pro status + +## Frontend Changes Needed + +1. **Workspace Management UI** + - List workspaces + - View workspace members + - Add/remove members + - Update member roles + +2. **Display Creation** + - Automatically assign to user's primary workspace + - Allow selecting workspace (if user has multiple) + +3. **Device Connection** + - Connect code should work with workspace + - Devices inherit workspace from user + +## Migration Commands + +Run migrations in order: + +```bash +php artisan migrate +``` + +The migration `2025_12_30_000003_create_workspaces_for_existing_users.php` will: +1. Create a workspace for each existing user +2. Migrate all user's displays, devices, calendars, and rooms to their workspace +3. Add the user as an owner member + +## Notes + +- Pro subscription is required to add team members +- Workspace owner cannot be removed +- All existing functionality remains backward compatible +- `user_id` fields are kept for direct ownership tracking + diff --git a/backend/resources/views/pages/admin.blade.php b/backend/resources/views/pages/admin.blade.php index 4cf449f..d8129d7 100644 --- a/backend/resources/views/pages/admin.blade.php +++ b/backend/resources/views/pages/admin.blade.php @@ -3,10 +3,17 @@ @section('title', 'Admin dashboard') @section('content') -
+
+ + +
+
+
+
+
+ + +
+
+
+
Total Users
+
{{ $allUsers->count() }}
+
+
+
+
+
+
+
+ + +
+
+
+
Users with Displays
+
{{ $allUsers->filter(fn($u) => $u->displays_count > 0)->count() }}
+
+
+
+
+
+
+
+ + +
+
+
+
Pro Users
+
{{ $allUsers->filter(fn($u) => $u->hasPro())->count() }}
+
+
+
+
+
+ +
+

All Users

+
+
+ +
+
+ + + + + + + + + + + + + + + @foreach($allUsers as $user) + + + + + + + + + + + @endforeach + +
NameEmailUsage TypeDisplaysProRegisteredLast ActivityActions
{{ $user->name }}{{ $user->email }}{{ $user->usage_type?->label() ?? '-' }}{{ $user->displays_count }} + @if($user->hasPro()) + Yes + @else + No + @endif + {{ $user->created_at->format('Y-m-d') }}{{ $user->last_activity_at ? $user->last_activity_at->format('Y-m-d') : 'Never' }} + + View + +
+
+
+
+
@endsection diff --git a/backend/resources/views/pages/admin/user.blade.php b/backend/resources/views/pages/admin/user.blade.php new file mode 100644 index 0000000..66ba6db --- /dev/null +++ b/backend/resources/views/pages/admin/user.blade.php @@ -0,0 +1,208 @@ +@extends('layouts.base') +@section('title', 'User Details - ' . $user->email) +@section('container_class', 'max-w-4xl') + +@section('content') + +
+
+

User Details

+

View and manage user account information

+
+ +
+ + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + +
+
+

Account Information

+
+
+
Email
+
{{ $user->email }}
+
+
+
Name
+
{{ $user->name }}
+
+
+
User ID
+
{{ $user->id }}
+
+
+
Usage Type
+
{{ $user->usage_type?->label() ?? 'Not set' }}
+
+
+
Created
+
{{ $user->created_at->format('Y-m-d H:i:s') }}
+
+
+
Last Activity
+
{{ $user->last_activity_at ? $user->last_activity_at->format('Y-m-d H:i:s') : 'Never' }}
+
+
+
+ + @if($user->hasPro() || $subscriptionInfo) +
+

Subscription Information

+
+
+
Plan
+
+ @if($user->is_unlimited) + Unlimited + @elseif($subscriptionInfo) + Pro + @else + Free + @endif +
+
+ @if($subscriptionInfo) +
+
Status
+
+ @php + $status = $subscriptionInfo['status']; + $statusLabel = ucwords(str_replace('_', ' ', $status)); + $statusColors = match($status) { + 'active' => 'bg-green-50 text-green-700 ring-green-600/20', + 'past_due' => 'bg-yellow-50 text-yellow-700 ring-yellow-600/20', + 'unpaid' => 'bg-red-50 text-red-700 ring-red-600/20', + 'cancelled' => 'bg-gray-50 text-gray-700 ring-gray-600/20', + 'on_trial' => 'bg-blue-50 text-blue-700 ring-blue-600/20', + 'paused' => 'bg-orange-50 text-orange-700 ring-orange-600/20', + default => 'bg-gray-50 text-gray-700 ring-gray-600/20', + }; + @endphp + + {{ $statusLabel }} + +
+
+
+
Monthly Price
+
${{ number_format($subscriptionInfo['price'], 2) }}
+
+
+
MRR
+
${{ number_format($subscriptionInfo['mrr'], 2) }}
+
+ @if($subscriptionInfo['ends_at']) +
+
Subscription Ends
+
{{ \Carbon\Carbon::parse($subscriptionInfo['ends_at'])->format('Y-m-d') }}
+
+ @endif + @endif +
+
+ @endif + +
+

Data Summary

+
+
+
Outlook Accounts
+
{{ $user->outlookAccounts->count() }}
+
+
+
Google Accounts
+
{{ $user->googleAccounts->count() }}
+
+
+
CalDAV Accounts
+
{{ $user->caldavAccounts->count() }}
+
+
+
Displays
+
{{ $user->displays->count() }}
+
+
+
Devices
+
{{ $user->devices->count() }}
+
+
+
Workspaces
+
{{ $user->workspaces->count() }}
+
+
+
+ + @if($user->id !== auth()->id()) +
+

⚠️ Delete User Account

+

+ This action cannot be undone. All data associated with this user will be permanently deleted: +

+
    +
  • All connected accounts (Outlook, Google, CalDAV)
  • +
  • All displays and their settings
  • +
  • All devices
  • +
  • All calendars and events
  • +
  • All rooms
  • +
  • All workspace memberships
  • +
  • All personal access tokens
  • +
+ +
+ @csrf + @method('DELETE') + +
+ + + @error('confirm_email') +

{{ $message }}

+ @enderror +
+ +
+ +
+
+
+ @else +
+

⚠️ Notice

+

+ You cannot delete your own account. Please ask another admin to perform this action if needed. +

+
+ @endif +
+
+@endsection diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index 162f0a0..8ed16aa 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -204,7 +204,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-
{{ $display->name }}
-
{{ $display->calendar->name }}
+
{{ $display->display_name }}
@@ -226,6 +226,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- {{ $display->calendar->caldavAccount->name }}
@endif +
{{ $display->calendar->name }}
diff --git a/backend/routes/web.php b/backend/routes/web.php index 04d1051..5bab3e6 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -116,6 +116,8 @@ })->name('billing.thanks'); Route::get('/admin', [AdminController::class, 'index'])->name('admin.index'); + Route::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show'); + Route::delete('/admin/users/{user}', [AdminController::class, 'deleteUser'])->name('admin.users.delete'); // Display image serving route Route::get('/displays/{display}/images/{type}', [DisplaySettingsController::class, 'serveImage']) From d565d48d634b6d9cb0737ce67de2f4f8ce72fbac Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 11:02:29 +0100 Subject: [PATCH 2/7] feat: Add displaying workspaces in the dashboard --- CONTRIBUTING.md | 1 + .../Http/Controllers/API/DeviceController.php | 9 +- .../Controllers/API/DisplayController.php | 18 +- .../Http/Controllers/DashboardController.php | 27 ++- .../Http/Controllers/DisplayController.php | 30 ++- .../Http/Requests/CreateDisplayRequest.php | 1 + backend/app/Models/User.php | 7 +- .../resources/views/pages/dashboard.blade.php | 196 ++++++++---------- .../views/pages/displays/create.blade.php | 23 ++ 9 files changed, 185 insertions(+), 127 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a14d59e..1525016 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,7 @@ Thank you for your interest in contributing to Spacepad! This document provides ### Coding Standards - Follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding style guide for PHP +- Always use import statements instead of inline fully qualified class names - See [Coding Standards](backend/docs/CODING_STANDARDS.md) for details - Use ESLint and Prettier for JavaScript/TypeScript - Write meaningful commit messages - Add comments for complex logic diff --git a/backend/app/Http/Controllers/API/DeviceController.php b/backend/app/Http/Controllers/API/DeviceController.php index e85ddd3..d6ffaed 100644 --- a/backend/app/Http/Controllers/API/DeviceController.php +++ b/backend/app/Http/Controllers/API/DeviceController.php @@ -25,8 +25,15 @@ public function changeDisplay(ChangeDisplayRequest $request): JsonResponse $device = auth()->user(); $data = $request->validated(); + if (!$device->workspace_id) { + return $this->error( + message: 'Device is not assigned to a workspace', + code: Response::HTTP_BAD_REQUEST + ); + } + $display = Display::query() - ->where('user_id', $device->user_id) + ->where('workspace_id', $device->workspace_id) ->find($data['display_id']); if (! $display) { diff --git a/backend/app/Http/Controllers/API/DisplayController.php b/backend/app/Http/Controllers/API/DisplayController.php index 6a2eda9..8b6d2a8 100644 --- a/backend/app/Http/Controllers/API/DisplayController.php +++ b/backend/app/Http/Controllers/API/DisplayController.php @@ -9,6 +9,7 @@ use App\Http\Resources\API\EventResource; use App\Models\Device; use App\Models\Display; +use App\Models\User; use App\Services\DisplayService; use App\Services\EventService; use App\Services\ImageService; @@ -30,13 +31,23 @@ public function index(): JsonResponse /** @var Device $device */ $device = auth()->user(); - if (!$device->workspace_id) { + if (!$device->user_id) { + return $this->success(data: []); + } + + $user = User::find($device->user_id); + if (!$user) { + return $this->success(data: []); + } + + // Get displays from all workspaces the user is a member of + $workspaceIds = $user->workspaces->pluck('id'); + if ($workspaceIds->isEmpty()) { return $this->success(data: []); } - // Get displays from device's workspace $displays = Display::query() - ->where('workspace_id', $device->workspace_id) + ->whereIn('workspace_id', $workspaceIds) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->with('settings') ->get(); @@ -44,6 +55,7 @@ public function index(): JsonResponse logger()->info('Display list requested', [ 'user_id' => $device->user_id, 'device_id' => $device->id, + 'workspace_ids' => $workspaceIds->toArray(), 'display_count' => $displays->count(), 'ip' => request()->ip(), ]); diff --git a/backend/app/Http/Controllers/DashboardController.php b/backend/app/Http/Controllers/DashboardController.php index 6d7b870..ba2dff2 100644 --- a/backend/app/Http/Controllers/DashboardController.php +++ b/backend/app/Http/Controllers/DashboardController.php @@ -6,6 +6,8 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; +use App\Services\InstanceService; +use App\Models\Display; class DashboardController extends Controller { @@ -20,8 +22,17 @@ public function __construct(protected OutlookService $outlookService) public function __invoke(): View|Factory|Application { $user = auth()->user(); + $user->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'workspaces']); + + // Get connect code for user $connectCode = $user->getConnectCode(); - $user->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'displays']); + + // Get displays from all workspaces user is a member of + $workspaceIds = $user->workspaces->pluck('id'); + $displays = Display::whereIn('workspace_id', $workspaceIds) + ->with(['workspace', 'calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount']) + ->get() + ->groupBy('workspace_id'); logger()->info('Dashboard page accessed', [ 'user_id' => $user->id, @@ -29,17 +40,27 @@ public function __invoke(): View|Factory|Application 'outlook_accounts_count' => $user->outlookAccounts->count(), 'google_accounts_count' => $user->googleAccounts->count(), 'caldav_accounts_count' => $user->caldavAccounts->count(), - 'displays_count' => $user->displays->count(), + 'displays_count' => $displays->flatten()->count(), + 'workspaces_count' => $user->workspaces->count(), 'ip' => request()->ip(), 'user_agent' => substr(request()->userAgent() ?? '', 0, 100), ]); + $isSelfHosted = config('settings.is_self_hosted'); + return view('pages.dashboard', [ 'outlookAccounts' => $user->outlookAccounts, 'googleAccounts' => $user->googleAccounts, 'caldavAccounts' => $user->caldavAccounts, - 'displays' => $user->displays, + 'displays' => $displays, // Grouped by workspace + 'displaysFlat' => $displays->flatten(), // Flat list for compatibility + 'workspaces' => $user->workspaces, 'connectCode' => $connectCode, + 'primaryWorkspace' => $user->primaryWorkspace(), + 'version' => config('settings.version', 'dev'), + 'appEnv' => config('app.env', 'production'), + 'appUrl' => config('app.url'), + 'isSelfHosted' => $isSelfHosted, ]); } } diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index c996dba..3f548dd 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -34,14 +34,15 @@ public function __construct( public function create(): View { - $outlookAccounts = auth()->user()->outlookAccounts; - $googleAccounts = auth()->user()->googleAccounts; - $caldavAccounts = auth()->user()->caldavAccounts; + $user = auth()->user(); + $workspaces = $user->workspaces()->withPivot('role')->get(); return view('pages.displays.create', [ - 'outlookAccounts' => $outlookAccounts, - 'googleAccounts' => $googleAccounts, - 'caldavAccounts' => $caldavAccounts, + 'outlookAccounts' => $user->outlookAccounts, + 'googleAccounts' => $user->googleAccounts, + 'caldavAccounts' => $user->caldavAccounts, + 'workspaces' => $workspaces, + 'defaultWorkspace' => $user->primaryWorkspace(), ]); } @@ -69,11 +70,24 @@ public function store(CreateDisplayRequest $request): RedirectResponse }; $user = auth()->user(); - $workspace = $user->primaryWorkspace(); - if (!$workspace) { + // Get workspace from request, or default to primary + $workspaceId = $validatedData['workspace_id'] ?? $user->primaryWorkspace()?->id; + + if (!$workspaceId) { return redirect()->back()->with('error', 'No workspace found. Please contact support.'); } + + // Verify user has access to this workspace + $workspace = $user->workspaces()->find($workspaceId); + if (!$workspace) { + return redirect()->back()->with('error', 'You do not have access to this workspace.'); + } + + // Check if user can create displays in this workspace (owner/admin) + if (!$workspace->canBeManagedBy($user)) { + return redirect()->back()->with('error', 'You do not have permission to create displays in this workspace.'); + } $display = DB::transaction(function () use ($validatedData, $workspace) { // Handle room or calendar selection diff --git a/backend/app/Http/Requests/CreateDisplayRequest.php b/backend/app/Http/Requests/CreateDisplayRequest.php index 5dc927e..b929dce 100644 --- a/backend/app/Http/Requests/CreateDisplayRequest.php +++ b/backend/app/Http/Requests/CreateDisplayRequest.php @@ -35,6 +35,7 @@ public function rules(): array 'provider' => 'required|string|in:outlook,google,caldav', 'room' => 'required_without:calendar|string', 'calendar' => 'required_without:room|string', + 'workspace_id' => 'nullable|string|exists:workspaces,id', ]; } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index ff328a0..430863f 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -167,6 +167,11 @@ public function hasAnyAccount(): bool return $this->outlookAccounts()->count() > 0 || $this->googleAccounts()->count() > 0 || $this->caldavAccounts()->count() > 0; } + /** + * Get or generate a connect code for this user + * + * @return string 6-digit connect code + */ public function getConnectCode(): string { $connectCode = cache()->get("user:$this->id:connect-code"); @@ -177,7 +182,7 @@ public function getConnectCode(): string } while (cache()->has("connect-code:$connectCode")); cache()->put("user:$this->id:connect-code", $connectCode, $expiresAt); - cache()->put("connect-code:$connectCode", auth()->id(), $expiresAt); + cache()->put("connect-code:$connectCode", $this->id, $expiresAt); } return $connectCode; diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index 8ed16aa..0bd7008 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -3,12 +3,12 @@ @section('actions') {{-- Instruction Banner --}} - @if(auth()->user()->hasAnyDisplay()) + @if(auth()->user()->hasAnyDisplay() && $connectCode)

Connect code

-

{{ chunk_split($connectCode, 3, ' ') }}

+

{{ chunk_split($connectCode, 3, ' ') }}

@@ -122,7 +122,12 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-

Displays

-

Overview of your displays and their status.

+

+ Overview of your displays and their status. + @if($workspaces->count() > 1) + ({{ $workspaces->count() }} workspaces) + @endif +

@if(auth()->user()->hasAnyDisplay()) @@ -200,118 +205,66 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- - @forelse($displays as $display) - - -
{{ $display->name }}
-
{{ $display->display_name }}
- - -
- @if($display->calendar->outlookAccount) -
- - {{ $display->calendar->outlookAccount->name }} -
- @endif - @if($display->calendar->googleAccount) -
- - {{ $display->calendar->googleAccount->name }} -
- @endif - @if($display->calendar->caldavAccount) -
- - {{ $display->calendar->caldavAccount->name }} + @if($workspaces->count() > 1) + @foreach($displays as $workspaceId => $workspaceDisplays) + @php + $workspace = $workspaces->firstWhere('id', $workspaceId); + @endphp + @if($workspace) + + +
+

+ {{ $workspace->name }} + ({{ $workspaceDisplays->count() }} display{{ $workspaceDisplays->count() !== 1 ? 's' : '' }}) +

+ + @if($workspace->pivot->role === \App\Enums\WorkspaceRole::OWNER->value) + Owner + @elseif($workspace->pivot->role === \App\Enums\WorkspaceRole::ADMIN->value) + Admin + @else + Member + @endif +
- @endif -
{{ $display->calendar->name }}
-
- - - - {{ $display->status->label() }} - - - -
-
- @if($display->devices->isNotEmpty()) -
-
-
-
- - -
+ + + @endif + @foreach($workspaceDisplays as $display) + + @endforeach + @endforeach + @if($displays->isEmpty()) + + +
+ +

+ One more step and you're set up +

+

Pick the calendar or room you would like to synchronize. You are able to connect multiple tablets to one display.

+ @if(! $isSelfHosted && auth()->user()->shouldUpgrade()) + + Create new display Pro + + @elseif($isSelfHosted && auth()->user()->shouldUpgrade()) + + Create new display Pro + @else -
-
-
- No devices + + + Create new display + @endif
- @if($display->last_sync_at) -
Last sync {{ $display->last_sync_at->diffForHumans() }}
- @endif -
- - -
-
- @csrf - @method('PATCH') - - -
- @if(auth()->user()->hasPro()) - - - - - - - @else - - - - - - - @endif -
- @csrf - @method('DELETE') - -
-
- - + + + @endif + @else + @forelse($displaysFlat as $display) + @empty @@ -338,6 +291,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- @endforelse + @endif
@@ -520,6 +474,25 @@ class="grow flex items-center justify-center gap-3 rounded-lg border border-gray
+ + {{-- Server Info (Self-hosted only) --}} + @if($isSelfHosted) +
+ + Self-hosted + + @if($version) + + v{{ $version }} + + @endif + @if($appUrl) + + {{ parse_url($appUrl, PHP_URL_HOST) }} ({{ $appEnv }}) + + @endif +
+ @endif @endsection @push('scripts') @@ -563,6 +536,7 @@ function closeConnectModal() { })); }); @endif + @endpush diff --git a/backend/resources/views/pages/displays/create.blade.php b/backend/resources/views/pages/displays/create.blade.php index 8ca07cb..6691844 100644 --- a/backend/resources/views/pages/displays/create.blade.php +++ b/backend/resources/views/pages/displays/create.blade.php @@ -41,6 +41,29 @@ class="block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring
+ @if($workspaces->count() > 1) +
+ +
+ +
+

Select which workspace this display should belong to.

+
+ @else + + @endif +

1. Select a calendar account

From f0f7e91d95befb57242b8126bff13d5f4bfa1989 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 11:24:31 +0100 Subject: [PATCH 3/7] feat: Check permissions for user workspaces --- .../Http/Controllers/API/DeviceController.php | 25 +++- backend/app/Services/DisplayService.php | 54 ++++----- backend/docs/CODING_STANDARDS.md | 64 ++++++++++ .../components/displays/table-row.blade.php | 114 ++++++++++++++++++ 4 files changed, 223 insertions(+), 34 deletions(-) create mode 100644 backend/docs/CODING_STANDARDS.md create mode 100644 backend/resources/views/components/displays/table-row.blade.php diff --git a/backend/app/Http/Controllers/API/DeviceController.php b/backend/app/Http/Controllers/API/DeviceController.php index d6ffaed..2e823bc 100644 --- a/backend/app/Http/Controllers/API/DeviceController.php +++ b/backend/app/Http/Controllers/API/DeviceController.php @@ -7,6 +7,7 @@ use App\Http\Resources\API\DeviceResource; use App\Models\Device; use App\Models\Display; +use App\Models\User; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -25,15 +26,33 @@ public function changeDisplay(ChangeDisplayRequest $request): JsonResponse $device = auth()->user(); $data = $request->validated(); - if (!$device->workspace_id) { + if (!$device->user_id) { return $this->error( - message: 'Device is not assigned to a workspace', + message: 'Device is not associated with a user', code: Response::HTTP_BAD_REQUEST ); } + $user = User::with('workspaces')->find($device->user_id); + if (!$user) { + return $this->error( + message: 'User not found', + code: Response::HTTP_NOT_FOUND + ); + } + + // Get all workspace IDs the user is a member of + $workspaceIds = $user->workspaces->pluck('id'); + if ($workspaceIds->isEmpty()) { + return $this->error( + message: 'User is not a member of any workspace', + code: Response::HTTP_BAD_REQUEST + ); + } + + // Find display in any of the user's workspaces $display = Display::query() - ->where('workspace_id', $device->workspace_id) + ->whereIn('workspace_id', $workspaceIds) ->find($data['display_id']); if (! $display) { diff --git a/backend/app/Services/DisplayService.php b/backend/app/Services/DisplayService.php index 22fdf7f..23d8ef9 100644 --- a/backend/app/Services/DisplayService.php +++ b/backend/app/Services/DisplayService.php @@ -5,6 +5,7 @@ use App\Data\PermissionResult; use App\Models\Device; use App\Models\Display; +use App\Models\User; class DisplayService { @@ -23,53 +24,44 @@ public function getDisplay(string $displayId) */ public function validateDisplayPermission(?string $displayId, string $deviceId, array $options = []): PermissionResult { - $device = Device::query()->find($deviceId); - if (!$device) { + $device = Device::with('user.workspaces')->find($deviceId); + + if (!$device || !$device->user_id) { return new PermissionResult(false, 'Device not found', 404); } - if (!$device->workspace_id) { - return new PermissionResult(false, 'Device not associated with a workspace', 404); + $user = $device->user; + if (!$user) { + return new PermissionResult(false, 'User not found', 404); } - $workspaceId = $device->workspace_id; + if (!$displayId) { + return new PermissionResult(false, 'Display ID is required', 400); + } - // Find display by ID, checking workspace access - $display = null; - if ($displayId) { - $display = Display::with('workspace.members')->find($displayId); - - if ($display) { - // Check access: device and display must be in the same workspace - if (!$display->workspace_id || $display->workspace_id !== $workspaceId) { - return new PermissionResult(false, 'Display not found', 404); - } - } + // Get all workspace IDs the user is a member of + $workspaceIds = $user->workspaces->pluck('id'); + if ($workspaceIds->isEmpty()) { + return new PermissionResult(false, 'User is not a member of any workspace', 403); } + // Find display in any of the user's workspaces + $display = Display::with('workspace.members') + ->whereIn('workspace_id', $workspaceIds) + ->find($displayId); + if (!$display) { return new PermissionResult(false, 'Display not found', 404); } + if ($display->isDeactivated()) { return new PermissionResult(false, 'Display is deactivated', 400); } - // Check Pro feature - check workspace owners + // Pro feature check: check if any workspace owner has Pro if (!empty($options['pro'])) { - $workspace = $display->workspace; - $hasPro = false; - - if ($workspace) { - $owners = $workspace->owners()->get(); - foreach ($owners as $owner) { - if ($owner->hasPro()) { - $hasPro = true; - break; - } - } - } - - if (!$hasPro) { + $workspaceOwner = $display->workspace->owners()->first(); + if (!$workspaceOwner || !$workspaceOwner->hasPro()) { return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); } } diff --git a/backend/docs/CODING_STANDARDS.md b/backend/docs/CODING_STANDARDS.md new file mode 100644 index 0000000..1a211c7 --- /dev/null +++ b/backend/docs/CODING_STANDARDS.md @@ -0,0 +1,64 @@ +# Coding Standards + +This document outlines coding standards and best practices for the Spacepad backend codebase. + +## Import Statements + +**Always use import statements at the top of files instead of inline fully qualified class names.** + +### ✅ Correct + +```php +get(); + $user = User::find(1); + $isValid = InstanceService::hasValidLicense(); + } +} +``` + +### ❌ Incorrect + +```php +get(); + $user = \App\Models\User::find(1); + $isValid = \App\Services\InstanceService::hasValidLicense(); + } +} +``` + +### Why? + +- **Readability**: Import statements make it clear which classes are used in a file +- **Maintainability**: Easier to refactor and understand dependencies +- **IDE Support**: Better autocomplete and navigation +- **PSR Standards**: Follows PSR-12 coding standard +- **Consistency**: Matches Laravel conventions + +### When to Use Fully Qualified Names + +Only use fully qualified class names (`\App\Models\...`) when: +- There's a naming conflict that requires disambiguation +- You're using a class from a different namespace that's not commonly imported + +In all other cases, use `use` statements at the top of the file. + diff --git a/backend/resources/views/components/displays/table-row.blade.php b/backend/resources/views/components/displays/table-row.blade.php new file mode 100644 index 0000000..ee96c9b --- /dev/null +++ b/backend/resources/views/components/displays/table-row.blade.php @@ -0,0 +1,114 @@ +@props(['display']) + + + +
{{ $display->name }}
+
{{ $display->display_name }}
+ + +
+ @if($display->calendar->outlookAccount) +
+ + {{ $display->calendar->outlookAccount->name }} +
+ @endif + @if($display->calendar->googleAccount) +
+ + {{ $display->calendar->googleAccount->name }} +
+ @endif + @if($display->calendar->caldavAccount) +
+ + {{ $display->calendar->caldavAccount->name }} +
+ @endif +
{{ $display->calendar->name }}
+
+ + + + {{ $display->status->label() }} + + + +
+
+ @if($display->devices->isNotEmpty()) +
+
+
+
+ + +
+ @else +
+
+
+ No devices + @endif +
+ @if($display->last_sync_at) +
Last sync {{ $display->last_sync_at->diffForHumans() }}
+ @endif +
+ + +
+
+ @csrf + @method('PATCH') + + +
+ @if(auth()->user()->hasPro()) + + + + + + + @else + + + + + + + @endif +
+ @csrf + @method('DELETE') + +
+
+ + + From a9a9b3b5c080a3271def249de6a7b1a42c5c2f9f Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 11:36:51 +0100 Subject: [PATCH 4/7] chore: Fix tests --- backend/app/Services/DisplayService.php | 2 +- .../tests/Feature/API/EventControllerTest.php | 13 ++++++- .../tests/Feature/DisplaySettingsApiTest.php | 36 +++++++++++++++---- backend/tests/Unit/DisplaySettingsTest.php | 23 +++++++++--- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/backend/app/Services/DisplayService.php b/backend/app/Services/DisplayService.php index 23d8ef9..fe613b0 100644 --- a/backend/app/Services/DisplayService.php +++ b/backend/app/Services/DisplayService.php @@ -36,7 +36,7 @@ public function validateDisplayPermission(?string $displayId, string $deviceId, } if (!$displayId) { - return new PermissionResult(false, 'Display ID is required', 400); + return new PermissionResult(false, 'Display not found', 404); } // Get all workspace IDs the user is a member of diff --git a/backend/tests/Feature/API/EventControllerTest.php b/backend/tests/Feature/API/EventControllerTest.php index 9218d5f..407cf15 100644 --- a/backend/tests/Feature/API/EventControllerTest.php +++ b/backend/tests/Feature/API/EventControllerTest.php @@ -16,11 +16,18 @@ beforeEach(function () { $this->user = User::factory()->create(); - $this->device = Device::factory()->create(['user_id' => $this->user->id]); + // User boot method automatically creates a workspace, get the primary workspace + $this->workspace = $this->user->primaryWorkspace(); + + $this->device = Device::factory()->create([ + 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, + ]); // Create calendar first $this->calendar = Calendar::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => 'test@example.com', 'name' => 'Test Calendar' ]); @@ -28,6 +35,7 @@ // Then create display with calendar $this->display = Display::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'status' => 'active' ]); @@ -58,6 +66,7 @@ $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); @@ -128,6 +137,7 @@ $googleAccount = GoogleAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); @@ -254,6 +264,7 @@ $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, + 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); diff --git a/backend/tests/Feature/DisplaySettingsApiTest.php b/backend/tests/Feature/DisplaySettingsApiTest.php index 1f65ccb..a508782 100644 --- a/backend/tests/Feature/DisplaySettingsApiTest.php +++ b/backend/tests/Feature/DisplaySettingsApiTest.php @@ -19,9 +19,17 @@ public function test_display_api_includes_settings() $user = User::factory()->create([ 'usage_type' => UsageType::PERSONAL, ]); + $workspace = $user->primaryWorkspace(); - $display = Display::factory()->create(['user_id' => $user->id]); - $device = Device::factory()->create(['user_id' => $user->id, 'display_id' => $display->id]); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'display_id' => $display->id, + ]); // Set some display settings DisplaySettings::setCheckInEnabled($display, true); @@ -54,9 +62,17 @@ public function test_display_api_includes_default_settings_when_none_set() $user = User::factory()->create([ 'usage_type' => UsageType::PERSONAL, ]); + $workspace = $user->primaryWorkspace(); - $display = Display::factory()->create(['user_id' => $user->id]); - $device = Device::factory()->create(['user_id' => $user->id, 'display_id' => $display->id]); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'display_id' => $display->id, + ]); $response = $this->actingAs($device) ->getJson('/api/displays'); @@ -73,9 +89,17 @@ public function test_display_settings_are_encrypted_in_database() $user = User::factory()->create([ 'usage_type' => UsageType::PERSONAL, ]); + $workspace = $user->primaryWorkspace(); - $display = Display::factory()->create(['user_id' => $user->id]); - Device::factory()->create(['user_id' => $user->id, 'display_id' => $display->id]); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); + Device::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'display_id' => $display->id, + ]); // Set display settings DisplaySettings::setCheckInEnabled($display, true); diff --git a/backend/tests/Unit/DisplaySettingsTest.php b/backend/tests/Unit/DisplaySettingsTest.php index 8ece9d3..8d95986 100644 --- a/backend/tests/Unit/DisplaySettingsTest.php +++ b/backend/tests/Unit/DisplaySettingsTest.php @@ -12,7 +12,11 @@ test('display settings helper can get and set boolean values', function () { $user = User::factory()->create(); - $display = Display::factory()->create(['user_id' => $user->id]); + $workspace = $user->primaryWorkspace(); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); // Test setting check-in enabled expect(DisplaySettings::setCheckInEnabled($display, true))->toBeTrue(); @@ -23,14 +27,21 @@ expect(DisplaySettings::isBookingEnabled($display))->toBeTrue(); // Test default values - $newDisplay = Display::factory()->create(['user_id' => $user->id]); + $newDisplay = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); expect(DisplaySettings::isCheckInEnabled($newDisplay))->toBeFalse(); expect(DisplaySettings::isBookingEnabled($newDisplay))->toBeFalse(); }); test('display model convenience methods work correctly', function () { $user = User::factory()->create(); - $display = Display::factory()->create(['user_id' => $user->id]); + $workspace = $user->primaryWorkspace(); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); // Test default values expect($display->isCheckInEnabled())->toBeFalse(); @@ -47,7 +58,11 @@ test('display settings can be retrieved as array', function () { $user = User::factory()->create(); - $display = Display::factory()->create(['user_id' => $user->id]); + $workspace = $user->primaryWorkspace(); + $display = Display::factory()->create([ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + ]); // Set some settings DisplaySettings::setCheckInEnabled($display, true); From 6c09c9f31848087bd3bc7d26a5e69e5fdf27cb95 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 12:51:04 +0100 Subject: [PATCH 5/7] feat: Add impersonating --- .../app/Http/Controllers/AdminController.php | 119 ++++++++++- .../Http/Controllers/WorkspaceController.php | 191 ------------------ .../components/impersonation-banner.blade.php | 20 ++ .../resources/views/layouts/base.blade.php | 2 +- .../resources/views/layouts/blank.blade.php | 3 + backend/resources/views/pages/admin.blade.php | 44 +++- .../vendor/pagination/tailwind.blade.php | 107 ++++++++++ backend/routes/web.php | 2 + 8 files changed, 276 insertions(+), 212 deletions(-) delete mode 100644 backend/app/Http/Controllers/WorkspaceController.php create mode 100644 backend/resources/views/components/impersonation-banner.blade.php create mode 100644 backend/resources/views/vendor/pagination/tailwind.blade.php diff --git a/backend/app/Http/Controllers/AdminController.php b/backend/app/Http/Controllers/AdminController.php index 2a222ba..ed53806 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -17,12 +17,27 @@ class AdminController extends Controller { - public function index() + /** + * Check if the current request is authorized for admin access + */ + private function checkAdminAccess(): void { $user = Auth::user(); + + // Prevent access if impersonating + if (session()->get('impersonating')) { + abort(403, 'Cannot access admin panel while impersonating. Please stop impersonating first.'); + } + + // Check if current user is admin if (!$user || !$user->isAdmin() || config('settings.is_self_hosted')) { abort(403); } + } + + public function index() + { + $this->checkAdminAccess(); $activeDisplays = Display::where('status', DisplayStatus::ACTIVE)->count(); $totalDisplays = Display::count(); @@ -114,11 +129,28 @@ public function index() return $user; }); - // All users for the users overview tab - $allUsers = User::query() + // All users for the users overview tab (paginated for performance) + $search = request()->get('search'); + $allUsersQuery = User::query() ->withCount('displays') + ->with(['subscriptions' => function($query) { + $query->where(function($q) { + $q->whereNull('ends_at') + ->orWhere('ends_at', '>', now()); + }); + }]); + + if ($search) { + $allUsersQuery->where(function($query) use ($search) { + $query->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } + + $allUsers = $allUsersQuery ->orderBy('created_at', 'desc') - ->get(); + ->paginate(50) + ->withQueryString(); return view('pages.admin', [ 'activeInstances' => $activeInstances, @@ -330,10 +362,9 @@ private function getSubscriptionPrice(string $subscriptionId, int $displaysCount */ public function showUser(User $user) { + $this->checkAdminAccess(); + $admin = Auth::user(); - if (!$admin || !$admin->isAdmin() || config('settings.is_self_hosted')) { - abort(403); - } // Load user relationships for display $user->load([ @@ -377,10 +408,9 @@ public function showUser(User $user) */ public function deleteUser(Request $request, User $user): RedirectResponse { + $this->checkAdminAccess(); + $admin = Auth::user(); - if (!$admin || !$admin->isAdmin() || config('settings.is_self_hosted')) { - abort(403); - } // Prevent deleting yourself if ($user->id === $admin->id) { @@ -530,4 +560,73 @@ public function deleteUser(Request $request, User $user): RedirectResponse return redirect()->route('admin.index') ->with('success', "User account {$user->email} and all associated data have been permanently deleted."); } + + /** + * Impersonate a user + */ + public function impersonate(User $user): RedirectResponse + { + $this->checkAdminAccess(); + + $admin = Auth::user(); + + // Prevent impersonating yourself + if ($admin->id === $user->id) { + return redirect()->route('admin.index') + ->with('error', 'You cannot impersonate yourself.'); + } + + // Store original admin ID in session + session()->put('impersonating', true); + session()->put('impersonator_id', $admin->id); + + // Log in as the target user + Auth::login($user); + + logger()->info('Admin started impersonating user', [ + 'admin_id' => $admin->id, + 'admin_email' => $admin->email, + 'impersonated_user_id' => $user->id, + 'impersonated_user_email' => $user->email, + ]); + + return redirect()->route('dashboard') + ->with('success', "You are now impersonating {$user->email}"); + } + + /** + * Stop impersonating and return to admin account + */ + public function stopImpersonating(): RedirectResponse + { + $impersonatorId = session()->get('impersonator_id'); + + if (!$impersonatorId) { + return redirect()->route('dashboard'); + } + + $impersonator = User::find($impersonatorId); + if (!$impersonator || !$impersonator->isAdmin()) { + session()->forget(['impersonating', 'impersonator_id']); + return redirect()->route('dashboard'); + } + + $impersonatedUser = Auth::user(); + + // Clear impersonation session + session()->forget(['impersonating', 'impersonator_id']); + + // Log back in as admin + Auth::login($impersonator); + + logger()->info('Admin stopped impersonating user', [ + 'admin_id' => $impersonator->id, + 'admin_email' => $impersonator->email, + 'impersonated_user_id' => $impersonatedUser->id, + 'impersonated_user_email' => $impersonatedUser->email, + ]); + + return redirect()->route('admin.index') + ->with('success', 'Stopped impersonating user.'); + } } diff --git a/backend/app/Http/Controllers/WorkspaceController.php b/backend/app/Http/Controllers/WorkspaceController.php deleted file mode 100644 index aeecd92..0000000 --- a/backend/app/Http/Controllers/WorkspaceController.php +++ /dev/null @@ -1,191 +0,0 @@ -user(); - $workspaces = $user->accessibleWorkspaces(); - $primaryWorkspace = $user->primaryWorkspace(); - - return view('pages.workspaces.index', [ - 'workspaces' => $workspaces, - 'primaryWorkspace' => $primaryWorkspace, - ]); - } - - /** - * Show workspace members page - */ - public function show(Workspace $workspace) - { - $user = auth()->user(); - - // Check if user has access to this workspace - if (!$workspace->hasMember($user)) { - abort(403, 'You do not have access to this workspace'); - } - - $workspace->load('members'); - - return view('pages.workspaces.show', [ - 'workspace' => $workspace, - 'userRole' => $workspace->getUserRole($user), - ]); - } - - /** - * Add a member to the workspace - */ - public function addMember(Request $request, Workspace $workspace): RedirectResponse - { - $user = auth()->user(); - - // Only workspace owners/admins can add members - if (!$workspace->canBeManagedBy($user)) { - abort(403, 'You do not have permission to add members to this workspace'); - } - - // Check if user has Pro (required for team features) - if (!$user->hasPro()) { - return back()->withErrors(['error' => 'Pro subscription is required to add team members']); - } - - $validated = $request->validate([ - 'email' => ['required', 'email', 'exists:users,email'], - 'role' => ['required', Rule::in([WorkspaceRole::ADMIN->value, WorkspaceRole::MEMBER->value])], - ]); - - $memberUser = User::where('email', $validated['email'])->first(); - - // Check if user is already a member - if ($workspace->hasMember($memberUser)) { - return back()->withErrors(['email' => 'User is already a member of this workspace']); - } - - // Add member (use WorkspaceMember::create to generate ULID) - $role = WorkspaceRole::from($validated['role']); - WorkspaceMember::create([ - 'workspace_id' => $workspace->id, - 'user_id' => $memberUser->id, - 'role' => $role, - ]); - - logger()->info('Workspace member added', [ - 'workspace_id' => $workspace->id, - 'added_by' => $user->id, - 'member_id' => $memberUser->id, - 'role' => $validated['role'], - ]); - - return back()->with('success', 'Member added successfully'); - } - - /** - * Update a member's role - */ - public function updateMemberRole(Request $request, Workspace $workspace, User $member): RedirectResponse - { - $user = auth()->user(); - - // Only workspace owners/admins can update roles - if (!$workspace->canBeManagedBy($user)) { - abort(403, 'You do not have permission to update member roles'); - } - - // Cannot change owner's role - $memberRole = $workspace->getUserRole($member); - if ($memberRole === WorkspaceRole::OWNER) { - return back()->withErrors(['error' => 'Cannot change the owner\'s role']); - } - - $validated = $request->validate([ - 'role' => ['required', Rule::in([WorkspaceRole::ADMIN->value, WorkspaceRole::MEMBER->value])], - ]); - - $role = WorkspaceRole::from($validated['role']); - WorkspaceMember::where('workspace_id', $workspace->id) - ->where('user_id', $member->id) - ->update(['role' => $role]); - - logger()->info('Workspace member role updated', [ - 'workspace_id' => $workspace->id, - 'updated_by' => $user->id, - 'member_id' => $member->id, - 'new_role' => $validated['role'], - ]); - - return back()->with('success', 'Member role updated successfully'); - } - - /** - * Remove a member from the workspace - */ - public function removeMember(Workspace $workspace, User $member): RedirectResponse - { - $user = auth()->user(); - - // Only workspace owners/admins can remove members - if (!$workspace->canBeManagedBy($user)) { - abort(403, 'You do not have permission to remove members from this workspace'); - } - - // Cannot remove owner - $memberRole = $workspace->getUserRole($member); - if ($memberRole === WorkspaceRole::OWNER) { - return back()->withErrors(['error' => 'Cannot remove the workspace owner']); - } - - $workspace->members()->detach($member->id); - - logger()->info('Workspace member removed', [ - 'workspace_id' => $workspace->id, - 'removed_by' => $user->id, - 'member_id' => $member->id, - ]); - - return back()->with('success', 'Member removed successfully'); - } - - /** - * Update workspace name - */ - public function update(Request $request, Workspace $workspace): RedirectResponse - { - $user = auth()->user(); - - // Only workspace owners/admins can update workspace - if (!$workspace->canBeManagedBy($user)) { - abort(403, 'You do not have permission to update this workspace'); - } - - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - ]); - - $workspace->update(['name' => $validated['name']]); - - logger()->info('Workspace updated', [ - 'workspace_id' => $workspace->id, - 'updated_by' => $user->id, - 'new_name' => $validated['name'], - ]); - - return back()->with('success', 'Workspace updated successfully'); - } -} - diff --git a/backend/resources/views/components/impersonation-banner.blade.php b/backend/resources/views/components/impersonation-banner.blade.php new file mode 100644 index 0000000..26b1e1e --- /dev/null +++ b/backend/resources/views/components/impersonation-banner.blade.php @@ -0,0 +1,20 @@ +@if(session('impersonating')) +
+
+
+
+ + ⚠️ You are impersonating {{ auth()->user()->email }} + +
+
+ @csrf + +
+
+
+
+@endif + diff --git a/backend/resources/views/layouts/base.blade.php b/backend/resources/views/layouts/base.blade.php index d752d3d..9f00b2f 100644 --- a/backend/resources/views/layouts/base.blade.php +++ b/backend/resources/views/layouts/base.blade.php @@ -13,7 +13,7 @@ @endif
- @if(auth()->user()->isAdmin() && !config('settings.is_self_hosted')) + @if(!session('impersonating') && auth()->user()->isAdmin() && !config('settings.is_self_hosted')) Admin diff --git a/backend/resources/views/layouts/blank.blade.php b/backend/resources/views/layouts/blank.blade.php index 839adcc..c696c2c 100644 --- a/backend/resources/views/layouts/blank.blade.php +++ b/backend/resources/views/layouts/blank.blade.php @@ -33,6 +33,9 @@ @includeWhen(config('googletagmanager.enabled') && config('googletagmanager.id'), 'googletagmanager::body') @stack('modals') + + @include('components.impersonation-banner') +
@yield('page')
diff --git a/backend/resources/views/pages/admin.blade.php b/backend/resources/views/pages/admin.blade.php index d8129d7..f29c0bd 100644 --- a/backend/resources/views/pages/admin.blade.php +++ b/backend/resources/views/pages/admin.blade.php @@ -3,7 +3,7 @@ @section('title', 'Admin dashboard') @section('content') -
+
diff --git a/backend/resources/views/vendor/pagination/tailwind.blade.php b/backend/resources/views/vendor/pagination/tailwind.blade.php new file mode 100644 index 0000000..28274a9 --- /dev/null +++ b/backend/resources/views/vendor/pagination/tailwind.blade.php @@ -0,0 +1,107 @@ +@if ($paginator->hasPages()) + +@endif + diff --git a/backend/routes/web.php b/backend/routes/web.php index 5bab3e6..0abd569 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -118,6 +118,8 @@ Route::get('/admin', [AdminController::class, 'index'])->name('admin.index'); Route::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show'); Route::delete('/admin/users/{user}', [AdminController::class, 'deleteUser'])->name('admin.users.delete'); + Route::post('/admin/users/{user}/impersonate', [AdminController::class, 'impersonate'])->name('admin.users.impersonate'); + Route::post('/admin/stop-impersonating', [AdminController::class, 'stopImpersonating'])->name('admin.stop-impersonating'); // Display image serving route Route::get('/displays/{display}/images/{type}', [DisplaySettingsController::class, 'serveImage']) From 67cce5af204ae248aa68b339aa4bc428733fbd4d Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 13:48:36 +0100 Subject: [PATCH 6/7] feat: Add workspace switch possibility --- .../app/Http/Controllers/AdminController.php | 3 + .../Controllers/CalDAVAccountsController.php | 5 + .../Http/Controllers/DashboardController.php | 59 ++++++--- .../Http/Controllers/DisplayController.php | 42 ++++--- .../Http/Controllers/WorkspaceController.php | 49 ++++++++ backend/app/Models/CalDAVAccount.php | 6 + backend/app/Models/GoogleAccount.php | 6 + backend/app/Models/OutlookAccount.php | 7 ++ backend/app/Models/User.php | 24 ++++ backend/app/Services/GoogleService.php | 6 + backend/app/Services/OutlookService.php | 6 + ...03_add_workspace_id_to_accounts_tables.php | 51 ++++++++ ..._create_workspaces_for_existing_users.php} | 20 +++- .../resources/views/pages/dashboard.blade.php | 112 ++++++------------ backend/routes/web.php | 3 + 15 files changed, 288 insertions(+), 111 deletions(-) create mode 100644 backend/app/Http/Controllers/WorkspaceController.php create mode 100644 backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php rename backend/database/migrations/{2025_12_30_000003_create_workspaces_for_existing_users.php => 2025_12_30_000004_create_workspaces_for_existing_users.php} (73%) diff --git a/backend/app/Http/Controllers/AdminController.php b/backend/app/Http/Controllers/AdminController.php index ed53806..a24edef 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -580,6 +580,9 @@ public function impersonate(User $user): RedirectResponse session()->put('impersonating', true); session()->put('impersonator_id', $admin->id); + // Clear any workspace selection from admin session - let impersonated user's workspace be selected + session()->forget('selected_workspace_id'); + // Log in as the target user Auth::login($user); diff --git a/backend/app/Http/Controllers/CalDAVAccountsController.php b/backend/app/Http/Controllers/CalDAVAccountsController.php index 7715d2c..3f1e9d1 100644 --- a/backend/app/Http/Controllers/CalDAVAccountsController.php +++ b/backend/app/Http/Controllers/CalDAVAccountsController.php @@ -41,9 +41,14 @@ public function store(Request $request): RedirectResponse ])->withInput(); } + // Get selected workspace (from session or default to primary) + $selectedWorkspace = auth()->user()->getSelectedWorkspace(); + $workspaceId = $selectedWorkspace?->id; + // Create the CalDAV account $account = CalDAVAccount::create([ 'user_id' => auth()->id(), + 'workspace_id' => $workspaceId, 'name' => parse_url($validated['url'], PHP_URL_HOST), 'email' => $validated['username'], 'url' => $validated['url'], diff --git a/backend/app/Http/Controllers/DashboardController.php b/backend/app/Http/Controllers/DashboardController.php index ba2dff2..2503d8b 100644 --- a/backend/app/Http/Controllers/DashboardController.php +++ b/backend/app/Http/Controllers/DashboardController.php @@ -8,6 +8,10 @@ use Illuminate\Contracts\View\View; use App\Services\InstanceService; use App\Models\Display; +use App\Models\Calendar; +use App\Models\OutlookAccount; +use App\Models\GoogleAccount; +use App\Models\CalDAVAccount; class DashboardController extends Controller { @@ -22,26 +26,45 @@ public function __construct(protected OutlookService $outlookService) public function __invoke(): View|Factory|Application { $user = auth()->user(); - $user->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'workspaces']); + + // Load workspaces with pivot data (role) - this includes all workspaces user is a member of + $workspaces = $user->workspaces()->withPivot('role')->get(); // Get connect code for user $connectCode = $user->getConnectCode(); - // Get displays from all workspaces user is a member of - $workspaceIds = $user->workspaces->pluck('id'); - $displays = Display::whereIn('workspace_id', $workspaceIds) - ->with(['workspace', 'calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount']) - ->get() - ->groupBy('workspace_id'); + // Get selected workspace (from session or default to primary) + $selectedWorkspace = $user->getSelectedWorkspace(); + + // Get displays from selected workspace only + if ($selectedWorkspace) { + $displays = Display::where('workspace_id', $selectedWorkspace->id) + ->with(['workspace', 'calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount']) + ->get(); + + // Get accounts for the selected workspace + $outlookAccounts = OutlookAccount::where('workspace_id', $selectedWorkspace->id) + ->get(); + $googleAccounts = GoogleAccount::where('workspace_id', $selectedWorkspace->id) + ->get(); + $caldavAccounts = CalDAVAccount::where('workspace_id', $selectedWorkspace->id) + ->get(); + } else { + $displays = collect(); + $outlookAccounts = collect(); + $googleAccounts = collect(); + $caldavAccounts = collect(); + } logger()->info('Dashboard page accessed', [ 'user_id' => $user->id, 'email' => $user->email, - 'outlook_accounts_count' => $user->outlookAccounts->count(), - 'google_accounts_count' => $user->googleAccounts->count(), - 'caldav_accounts_count' => $user->caldavAccounts->count(), - 'displays_count' => $displays->flatten()->count(), - 'workspaces_count' => $user->workspaces->count(), + 'outlook_accounts_count' => $outlookAccounts->count(), + 'google_accounts_count' => $googleAccounts->count(), + 'caldav_accounts_count' => $caldavAccounts->count(), + 'displays_count' => $displays->count(), + 'workspaces_count' => $workspaces->count(), + 'selected_workspace_id' => $selectedWorkspace?->id, 'ip' => request()->ip(), 'user_agent' => substr(request()->userAgent() ?? '', 0, 100), ]); @@ -49,12 +72,12 @@ public function __invoke(): View|Factory|Application $isSelfHosted = config('settings.is_self_hosted'); return view('pages.dashboard', [ - 'outlookAccounts' => $user->outlookAccounts, - 'googleAccounts' => $user->googleAccounts, - 'caldavAccounts' => $user->caldavAccounts, - 'displays' => $displays, // Grouped by workspace - 'displaysFlat' => $displays->flatten(), // Flat list for compatibility - 'workspaces' => $user->workspaces, + 'outlookAccounts' => $outlookAccounts, + 'googleAccounts' => $googleAccounts, + 'caldavAccounts' => $caldavAccounts, + 'displays' => $displays, + 'workspaces' => $workspaces, + 'selectedWorkspace' => $selectedWorkspace, 'connectCode' => $connectCode, 'primaryWorkspace' => $user->primaryWorkspace(), 'version' => config('settings.version', 'dev'), diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index 3f548dd..197b555 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -36,13 +36,25 @@ public function create(): View { $user = auth()->user(); $workspaces = $user->workspaces()->withPivot('role')->get(); + $selectedWorkspace = $user->getSelectedWorkspace(); + + // Filter accounts to only show those for the selected workspace + if ($selectedWorkspace) { + $outlookAccounts = $user->outlookAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); + $googleAccounts = $user->googleAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); + $caldavAccounts = $user->caldavAccounts()->where('workspace_id', $selectedWorkspace->id)->get(); + } else { + $outlookAccounts = $user->outlookAccounts; + $googleAccounts = $user->googleAccounts; + $caldavAccounts = $user->caldavAccounts; + } return view('pages.displays.create', [ - 'outlookAccounts' => $user->outlookAccounts, - 'googleAccounts' => $user->googleAccounts, - 'caldavAccounts' => $user->caldavAccounts, + 'outlookAccounts' => $outlookAccounts, + 'googleAccounts' => $googleAccounts, + 'caldavAccounts' => $caldavAccounts, 'workspaces' => $workspaces, - 'defaultWorkspace' => $user->primaryWorkspace(), + 'defaultWorkspace' => $selectedWorkspace ?? $user->primaryWorkspace(), ]); } @@ -71,8 +83,10 @@ public function store(CreateDisplayRequest $request): RedirectResponse $user = auth()->user(); - // Get workspace from request, or default to primary - $workspaceId = $validatedData['workspace_id'] ?? $user->primaryWorkspace()?->id; + // Get workspace from request, session (selected workspace), or default to primary + $workspaceId = $validatedData['workspace_id'] + ?? session()->get('selected_workspace_id') + ?? $user->primaryWorkspace()?->id; if (!$workspaceId) { return redirect()->back()->with('error', 'No workspace found. Please contact support.'); @@ -162,7 +176,7 @@ private function createCalendar(array $validatedData, $workspace): Calendar $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarId, - 'user_id' => $userId, + 'workspace_id' => $workspace->id, ], [ 'calendar_id' => $calendarId, 'user_id' => $userId, @@ -171,14 +185,9 @@ private function createCalendar(array $validatedData, $workspace): Calendar 'name' => $calendarName, ]); - // Update workspace_id if calendar already existed - if (!$calendar->workspace_id) { - $calendar->update(['workspace_id' => $workspace->id]); - } - Room::firstOrCreate([ 'email_address' => $calendarId, - 'user_id' => $userId, + 'workspace_id' => $workspace->id, ], [ 'email_address' => $calendarId, 'user_id' => $userId, @@ -195,7 +204,7 @@ private function createCalendar(array $validatedData, $workspace): Calendar $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarData[0], - 'user_id' => $userId, + 'workspace_id' => $workspace->id, ], [ 'user_id' => $userId, 'workspace_id' => $workspace->id, @@ -204,11 +213,6 @@ private function createCalendar(array $validatedData, $workspace): Calendar 'name' => $calendarName, ]); - // Update workspace_id if calendar already existed - if (!$calendar->workspace_id) { - $calendar->update(['workspace_id' => $workspace->id]); - } - return $calendar; } diff --git a/backend/app/Http/Controllers/WorkspaceController.php b/backend/app/Http/Controllers/WorkspaceController.php new file mode 100644 index 0000000..f464312 --- /dev/null +++ b/backend/app/Http/Controllers/WorkspaceController.php @@ -0,0 +1,49 @@ +validate([ + 'workspace_id' => 'required|string|exists:workspaces,id', + ]); + + $user = Auth::user(); + $workspaceId = $request->input('workspace_id'); + + // Validate user has access to this workspace (checks membership, not Pro status) + // This works for both regular users and impersonated users + $workspace = $user->workspaces()->find($workspaceId); + if (!$workspace) { + abort(403, 'You do not have access to this workspace.'); + } + + // Store selected workspace in session + // This persists during impersonation since we're using the impersonated user's session + session()->put('selected_workspace_id', $workspace->id); + + logger()->info('User switched workspace', [ + 'user_id' => $user->id, + 'workspace_id' => $workspace->id, + 'workspace_name' => $workspace->name, + 'is_impersonating' => session()->has('impersonating'), + ]); + + return redirect()->route('dashboard')->with('success', "Switched to workspace: {$workspace->name}"); + } +} + diff --git a/backend/app/Models/CalDAVAccount.php b/backend/app/Models/CalDAVAccount.php index dab6413..9076fd1 100644 --- a/backend/app/Models/CalDAVAccount.php +++ b/backend/app/Models/CalDAVAccount.php @@ -25,6 +25,7 @@ class CalDAVAccount extends Model 'status', 'permission_type', 'user_id', + 'workspace_id', 'url', 'username', 'password', @@ -49,4 +50,9 @@ public function calendars(): HasMany { return $this->hasMany(Calendar::class, 'caldav_account_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } } diff --git a/backend/app/Models/GoogleAccount.php b/backend/app/Models/GoogleAccount.php index a79bccb..7323ac3 100644 --- a/backend/app/Models/GoogleAccount.php +++ b/backend/app/Models/GoogleAccount.php @@ -27,6 +27,7 @@ class GoogleAccount extends Model 'service_account_file_path', 'booking_method', 'user_id', + 'workspace_id', 'google_id', 'token', 'refresh_token', @@ -61,4 +62,9 @@ public function isBusiness(): bool { return !empty($this->hosted_domain); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } } diff --git a/backend/app/Models/OutlookAccount.php b/backend/app/Models/OutlookAccount.php index 13dc95b..aae69d3 100644 --- a/backend/app/Models/OutlookAccount.php +++ b/backend/app/Models/OutlookAccount.php @@ -9,6 +9,7 @@ use App\Enums\AccountStatus; use App\Enums\PermissionType; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class OutlookAccount extends Model { @@ -23,6 +24,7 @@ class OutlookAccount extends Model 'status', 'permission_type', 'user_id', + 'workspace_id', 'outlook_id', 'token', 'refresh_token', @@ -51,4 +53,9 @@ public function calendars(): HasMany { return $this->hasMany(Calendar::class, 'outlook_account_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 430863f..aec4c75 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -279,4 +279,28 @@ public function isAdmin(): bool { return (bool) $this->is_admin; } + + /** + * Get the currently selected workspace (from session) or default to primary workspace + * + * Note: This works for all users (including non-Pro users) who are members of workspaces. + * Workspace access is based on membership, not Pro status. + */ + public function getSelectedWorkspace(): ?Workspace + { + $selectedWorkspaceId = session()->get('selected_workspace_id'); + + if ($selectedWorkspaceId) { + // Validate user has access to the selected workspace (checks membership, not Pro status) + $workspace = $this->workspaces()->find($selectedWorkspaceId); + if ($workspace) { + return $workspace; + } + // If selected workspace is invalid or user no longer has access, clear it from session + session()->forget('selected_workspace_id'); + } + + // Default to primary workspace (first owned workspace, or first workspace user is a member of) + return $this->primaryWorkspace(); + } } diff --git a/backend/app/Services/GoogleService.php b/backend/app/Services/GoogleService.php index e30c76d..a655575 100644 --- a/backend/app/Services/GoogleService.php +++ b/backend/app/Services/GoogleService.php @@ -59,14 +59,20 @@ public function authenticateGoogleAccount(string $authCode, PermissionType $perm $googleService = new Oauth2($this->client); $googleUserInfo = $googleService->userinfo->get(); + // Get selected workspace (from session or default to primary) + $selectedWorkspace = auth()->user()->getSelectedWorkspace(); + $workspaceId = $selectedWorkspace?->id; + // Save the user's Google account and tokens in the database return GoogleAccount::updateOrCreate( [ 'user_id' => auth()->id(), 'google_id' => $googleUserInfo->id, + 'workspace_id' => $workspaceId, ], [ 'user_id' => auth()->id(), + 'workspace_id' => $workspaceId, 'email' => $googleUserInfo->email, 'name' => $googleUserInfo->name, 'avatar' => $googleUserInfo->picture, diff --git a/backend/app/Services/OutlookService.php b/backend/app/Services/OutlookService.php index 772a141..beb14dd 100644 --- a/backend/app/Services/OutlookService.php +++ b/backend/app/Services/OutlookService.php @@ -111,14 +111,20 @@ public function authenticateOutlookAccount(string $authCode, string|PermissionTy $tenantId = $this->getTenantId($tokenData['access_token']); + // Get selected workspace (from session or default to primary) + $selectedWorkspace = auth()->user()->getSelectedWorkspace(); + $workspaceId = $selectedWorkspace?->id; + // Save the Outlook account and tokens return OutlookAccount::updateOrCreate( [ 'user_id' => auth()->id(), 'outlook_id' => $user['id'], + 'workspace_id' => $workspaceId, ], [ 'user_id' => auth()->id(), + 'workspace_id' => $workspaceId, 'email' => $user['mail'] ?? $user['userPrincipalName'], 'name' => $user['displayName'], 'tenant_id' => $tenantId, diff --git a/backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php b/backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php new file mode 100644 index 0000000..019c7bc --- /dev/null +++ b/backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php @@ -0,0 +1,51 @@ +foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to google_accounts + Schema::table('google_accounts', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + + // Add workspace_id to caldav_accounts + Schema::table('caldav_accounts', function (Blueprint $table) { + $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('outlook_accounts', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('google_accounts', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + + Schema::table('caldav_accounts', function (Blueprint $table) { + $table->dropForeign(['workspace_id']); + $table->dropColumn('workspace_id'); + }); + } +}; + diff --git a/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php b/backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php similarity index 73% rename from backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php rename to backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php index bdcf194..318369b 100644 --- a/backend/database/migrations/2025_12_30_000003_create_workspaces_for_existing_users.php +++ b/backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php @@ -10,7 +10,10 @@ use App\Models\Display; use App\Models\Device; use App\Models\Calendar; -use App\Models\Room; +use App\Models\Room; +use App\Models\OutlookAccount; +use App\Models\GoogleAccount; +use App\Models\CalDAVAccount; use App\Enums\WorkspaceRole; return new class extends Migration @@ -51,6 +54,21 @@ public function up(): void // Migrate rooms to workspace Room::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); + + // Migrate Outlook accounts to workspace + OutlookAccount::where('user_id', $user->id) + ->whereNull('workspace_id') + ->update(['workspace_id' => $workspace->id]); + + // Migrate Google accounts to workspace + GoogleAccount::where('user_id', $user->id) + ->whereNull('workspace_id') + ->update(['workspace_id' => $workspace->id]); + + // Migrate CalDAV accounts to workspace + CalDAVAccount::where('user_id', $user->id) + ->whereNull('workspace_id') + ->update(['workspace_id' => $workspace->id]); } }); } diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index 0bd7008..dff99c8 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -2,15 +2,42 @@ @section('title', 'Management dashboard') @section('actions') - {{-- Instruction Banner --}} - @if(auth()->user()->hasAnyDisplay() && $connectCode) -
-
-

Connect code

-
-

{{ chunk_split($connectCode, 3, ' ') }}

+ {{-- Workspace Selector and Connect Code --}} + @if($workspaces->count() > 1 || $connectCode) +
+ @if($workspaces->count() > 1) +
+ @csrf +
+ + +
+
+ @endif + @if(auth()->user()->hasAnyDisplay() && $connectCode) +
+

Connect code

+
+

{{ chunk_split($connectCode, 3, ' ') }}

+
-
+ @endif
@endif @endsection @@ -124,9 +151,6 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font-

Displays

Overview of your displays and their status. - @if($workspaces->count() > 1) - ({{ $workspaces->count() }} workspaces) - @endif

@@ -205,66 +229,8 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- - @if($workspaces->count() > 1) - @foreach($displays as $workspaceId => $workspaceDisplays) - @php - $workspace = $workspaces->firstWhere('id', $workspaceId); - @endphp - @if($workspace) - - -
-

- {{ $workspace->name }} - ({{ $workspaceDisplays->count() }} display{{ $workspaceDisplays->count() !== 1 ? 's' : '' }}) -

- - @if($workspace->pivot->role === \App\Enums\WorkspaceRole::OWNER->value) - Owner - @elseif($workspace->pivot->role === \App\Enums\WorkspaceRole::ADMIN->value) - Admin - @else - Member - @endif - -
- - - @endif - @foreach($workspaceDisplays as $display) - - @endforeach - @endforeach - @if($displays->isEmpty()) - - -
- -

- One more step and you're set up -

-

Pick the calendar or room you would like to synchronize. You are able to connect multiple tablets to one display.

- @if(! $isSelfHosted && auth()->user()->shouldUpgrade()) - - Create new display Pro - - @elseif($isSelfHosted && auth()->user()->shouldUpgrade()) - - Create new display Pro - - @else - - - Create new display - - @endif -
- - - @endif - @else - @forelse($displaysFlat as $display) - + @forelse($displays as $display) + @empty @@ -284,14 +250,14 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- @else - Create new display + + Create new display @endif
@endforelse - @endif
diff --git a/backend/routes/web.php b/backend/routes/web.php index 0abd569..023ee3b 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -17,6 +17,7 @@ use App\Http\Controllers\LicenseController; use Illuminate\Support\Facades\Route; use App\Http\Controllers\AdminController; +use App\Http\Controllers\WorkspaceController; Route::get('/login', [LoginController::class, 'create']) ->middleware('guest') @@ -99,6 +100,8 @@ Route::post('/license/validate', [LicenseController::class, 'validateLicense'])->name('license.validate'); + Route::post('/workspaces/switch', [WorkspaceController::class, 'switch'])->name('workspaces.switch'); + Route::get('/billing/thanks', function () { \Spatie\GoogleTagManager\GoogleTagManagerFacade::flashPush([ 'event' => 'purchase', From 89ebc05703d9a1ad6ede73b30326dda20dba01a9 Mon Sep 17 00:00:00 2001 From: Martijn van de Wetering Date: Sun, 11 Jan 2026 14:19:44 +0100 Subject: [PATCH 7/7] feat: Make sure users in other pro workspaces can access pro features --- .../Http/Controllers/DisplayController.php | 4 +- .../Controllers/DisplaySettingsController.php | 12 +++--- backend/app/Models/User.php | 40 +++++++++++++++++++ backend/app/Models/Workspace.php | 14 +++++++ backend/app/Services/DisplayService.php | 3 +- .../components/displays/table-row.blade.php | 2 +- .../resources/views/layouts/base.blade.php | 2 +- .../resources/views/pages/dashboard.blade.php | 8 ++-- .../views/pages/displays/create.blade.php | 2 +- .../pages/displays/customization.blade.php | 2 +- .../views/pages/displays/settings.blade.php | 2 +- 11 files changed, 72 insertions(+), 19 deletions(-) diff --git a/backend/app/Http/Controllers/DisplayController.php b/backend/app/Http/Controllers/DisplayController.php index 197b555..6902276 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -68,8 +68,8 @@ public function store(CreateDisplayRequest $request): RedirectResponse $provider = $validatedData['provider']; $accountId = $validatedData['account']; - // Check on access to create multiple displays - if (auth()->user()->shouldUpgrade()) { + // Check on access to create multiple displays (workspace-aware Pro check) + if (auth()->user()->shouldUpgradeForCurrentWorkspace()) { return redirect()->back()->with('error', 'You require an active Pro license to create multiple displays.'); } diff --git a/backend/app/Http/Controllers/DisplaySettingsController.php b/backend/app/Http/Controllers/DisplaySettingsController.php index 531f75a..ab906f0 100644 --- a/backend/app/Http/Controllers/DisplaySettingsController.php +++ b/backend/app/Http/Controllers/DisplaySettingsController.php @@ -21,8 +21,8 @@ public function index(Display $display): View { $this->authorize('update', $display); - // Check if user has Pro access - if (!auth()->user()->hasPro()) { + // Check if user has Pro access (workspace-aware) + if (!auth()->user()->hasProForCurrentWorkspace()) { return redirect()->route('dashboard')->with('error', 'Display settings are only available for Pro users.'); } @@ -35,8 +35,8 @@ public function update(Request $request, Display $display): RedirectResponse { $this->authorize('update', $display); - // Check if user has Pro access - if (!auth()->user()->hasPro()) { + // Check if user has Pro access (workspace-aware) + if (!auth()->user()->hasProForCurrentWorkspace()) { return redirect()->route('dashboard')->with('error', 'Display settings are only available for Pro users.'); } @@ -103,7 +103,7 @@ public function customization(Display $display): View { $this->authorize('update', $display); - if (!auth()->user()->hasPro()) { + if (!auth()->user()->hasProForCurrentWorkspace()) { return redirect()->route('dashboard')->with('error', 'Display customization is only available for Pro users.'); } @@ -116,7 +116,7 @@ public function updateCustomization(UpdateDisplayCustomizationRequest $request, { $this->authorize('update', $display); - if (!auth()->user()->hasPro()) { + if (!auth()->user()->hasProForCurrentWorkspace()) { return redirect()->route('dashboard')->with('error', 'Display customization is only available for Pro users.'); } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index aec4c75..1c267f6 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -206,6 +206,26 @@ public function hasPro(): bool return $this->is_unlimited || $this->subscribed(); } + /** + * Check if the user has Pro for the current workspace context. + * Returns true if the user has Pro OR if the selected workspace has Pro (any owner has Pro). + */ + public function hasProForCurrentWorkspace(): bool + { + // If user has Pro, they have Pro everywhere + if ($this->hasPro()) { + return true; + } + + // Check if the selected workspace has Pro (any owner has Pro) + $selectedWorkspace = $this->getSelectedWorkspace(); + if ($selectedWorkspace && $selectedWorkspace->hasPro()) { + return true; + } + + return false; + } + /** * Check if the user should be treated as a business user */ @@ -236,6 +256,26 @@ public function shouldUpgrade(): bool return ! $this->hasPro() && $this->hasAnyDisplay(); } + /** + * Check if the user should upgrade to Pro for the current workspace context. + * Returns false if the user has Pro OR if the selected workspace has Pro. + */ + public function shouldUpgradeForCurrentWorkspace(): bool + { + // If user has Pro for current workspace, no upgrade needed + if ($this->hasProForCurrentWorkspace()) { + return false; + } + + // Self Hosted: If the user is a personal user, use a soft limit + if (config('settings.is_self_hosted') && $this->isPersonalUser()) { + return false; + } + + // Cloud Hosted: If the user is a business user and doesn't have Pro, they should upgrade + return $this->hasAnyDisplay(); + } + public function getCheckoutUrl(?string $redirectUrl = null): ?Checkout { $redirectUrl ??= route('dashboard'); diff --git a/backend/app/Models/Workspace.php b/backend/app/Models/Workspace.php index d3de3bf..45bd964 100644 --- a/backend/app/Models/Workspace.php +++ b/backend/app/Models/Workspace.php @@ -114,5 +114,19 @@ public function getUserRole(User $user): ?WorkspaceRole $role = $member->pivot->role; return $role instanceof WorkspaceRole ? $role : WorkspaceRole::from($role); } + + /** + * Check if this workspace has Pro (any owner has Pro) + */ + public function hasPro(): bool + { + $owners = $this->owners()->with('subscriptions')->get(); + foreach ($owners as $owner) { + if ($owner->hasPro()) { + return true; + } + } + return false; + } } diff --git a/backend/app/Services/DisplayService.php b/backend/app/Services/DisplayService.php index fe613b0..ebebe08 100644 --- a/backend/app/Services/DisplayService.php +++ b/backend/app/Services/DisplayService.php @@ -60,8 +60,7 @@ public function validateDisplayPermission(?string $displayId, string $deviceId, // Pro feature check: check if any workspace owner has Pro if (!empty($options['pro'])) { - $workspaceOwner = $display->workspace->owners()->first(); - if (!$workspaceOwner || !$workspaceOwner->hasPro()) { + if (!$display->workspace->hasPro()) { return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); } } diff --git a/backend/resources/views/components/displays/table-row.blade.php b/backend/resources/views/components/displays/table-row.blade.php index ee96c9b..e603051 100644 --- a/backend/resources/views/components/displays/table-row.blade.php +++ b/backend/resources/views/components/displays/table-row.blade.php @@ -86,7 +86,7 @@ @endif - @if(auth()->user()->hasPro()) + @if(auth()->user()->hasProForCurrentWorkspace()) diff --git a/backend/resources/views/layouts/base.blade.php b/backend/resources/views/layouts/base.blade.php index 9f00b2f..42ff7d4 100644 --- a/backend/resources/views/layouts/base.blade.php +++ b/backend/resources/views/layouts/base.blade.php @@ -8,7 +8,7 @@ Logo
Spacepad - @if(auth()->user()->hasPro()) + @if(auth()->user()->hasProForCurrentWorkspace()) Pro @endif diff --git a/backend/resources/views/pages/dashboard.blade.php b/backend/resources/views/pages/dashboard.blade.php index dff99c8..3ebab16 100644 --- a/backend/resources/views/pages/dashboard.blade.php +++ b/backend/resources/views/pages/dashboard.blade.php @@ -114,7 +114,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- {{-- Commercial Banner --}} - @if(! auth()->user()->hasPro() && auth()->user()->hasAnyDisplay()) + @if(! auth()->user()->hasProForCurrentWorkspace() && auth()->user()->hasAnyDisplay())
@@ -161,7 +161,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- @endif @if(auth()->user()->can('create', \App\Models\Display::class)) - @if(auth()->user()->shouldUpgrade()) + @if(auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro @@ -240,11 +240,11 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- One more step and you're set up

Pick the calendar or room you would like to synchronize. You are able to connect multiple tablets to one display.

- @if(! $isSelfHosted && auth()->user()->shouldUpgrade()) + @if(! $isSelfHosted && auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro - @elseif($isSelfHosted && auth()->user()->shouldUpgrade()) + @elseif($isSelfHosted && auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro diff --git a/backend/resources/views/pages/displays/create.blade.php b/backend/resources/views/pages/displays/create.blade.php index 6691844..5608afe 100644 --- a/backend/resources/views/pages/displays/create.blade.php +++ b/backend/resources/views/pages/displays/create.blade.php @@ -5,7 +5,7 @@ @php $isSelfHosted = config('settings.is_self_hosted'); $checkout = auth()->user()->getCheckoutUrl(route('displays.create')); - $userHasPro = auth()->user()->hasPro(); + $userHasPro = auth()->user()->hasProForCurrentWorkspace(); @endphp {{-- License Key Modal --}} diff --git a/backend/resources/views/pages/displays/customization.blade.php b/backend/resources/views/pages/displays/customization.blade.php index 67c868e..c2c87af 100644 --- a/backend/resources/views/pages/displays/customization.blade.php +++ b/backend/resources/views/pages/displays/customization.blade.php @@ -3,7 +3,7 @@ @section('container_class', 'max-w-2xl') @section('content') - @if(!auth()->user()->hasPro()) + @if(!auth()->user()->hasProForCurrentWorkspace())
diff --git a/backend/resources/views/pages/displays/settings.blade.php b/backend/resources/views/pages/displays/settings.blade.php index ac0eac5..3e05d86 100644 --- a/backend/resources/views/pages/displays/settings.blade.php +++ b/backend/resources/views/pages/displays/settings.blade.php @@ -3,7 +3,7 @@ @section('container_class', 'max-w-2xl') @section('content') - @if(!auth()->user()->hasPro()) + @if(!auth()->user()->hasProForCurrentWorkspace())