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/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/DeviceController.php b/backend/app/Http/Controllers/API/DeviceController.php index e85ddd3..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,8 +26,33 @@ public function changeDisplay(ChangeDisplayRequest $request): JsonResponse $device = auth()->user(); $data = $request->validated(); + if (!$device->user_id) { + return $this->error( + 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('user_id', $device->user_id) + ->whereIn('workspace_id', $workspaceIds) ->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 4bc108e..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,8 +31,23 @@ public function index(): JsonResponse /** @var Device $device */ $device = auth()->user(); + 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: []); + } + $displays = Display::query() - ->where('user_id', $device->user_id) + ->whereIn('workspace_id', $workspaceIds) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->with('settings') ->get(); @@ -39,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/AdminController.php b/backend/app/Http/Controllers/AdminController.php index 5c48485..a24edef 100644 --- a/backend/app/Http/Controllers/AdminController.php +++ b/backend/app/Http/Controllers/AdminController.php @@ -6,19 +6,38 @@ 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 { - 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(); @@ -110,10 +129,34 @@ public function index() return $user; }); + // 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') + ->paginate(50) + ->withQueryString(); + return view('pages.admin', [ 'activeInstances' => $activeInstances, 'activeDisplays' => $activeDisplays, 'payingUsers' => $payingUsers, + 'allUsers' => $allUsers, 'activeDisplaysCount' => $activeDisplays->count(), 'totalDisplays' => $totalDisplays, 'activeInstancesCount' => $activeInstances->count(), @@ -313,4 +356,280 @@ private function getSubscriptionPrice(string $subscriptionId, int $displaysCount return null; } } + + /** + * Show user details page + */ + public function showUser(User $user) + { + $this->checkAdminAccess(); + + $admin = Auth::user(); + + // 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 + { + $this->checkAdminAccess(); + + $admin = Auth::user(); + + // 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."); + } + + /** + * 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); + + // 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); + + 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/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 6d7b870..2503d8b 100644 --- a/backend/app/Http/Controllers/DashboardController.php +++ b/backend/app/Http/Controllers/DashboardController.php @@ -6,6 +6,12 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; 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 { @@ -20,26 +26,64 @@ public function __construct(protected OutlookService $outlookService) public function __invoke(): View|Factory|Application { $user = auth()->user(); + + // 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(); - $user->load(['outlookAccounts', 'googleAccounts', 'caldavAccounts', 'displays']); + + // 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' => $user->displays->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), ]); + $isSelfHosted = config('settings.is_self_hosted'); + return view('pages.dashboard', [ - 'outlookAccounts' => $user->outlookAccounts, - 'googleAccounts' => $user->googleAccounts, - 'caldavAccounts' => $user->caldavAccounts, - 'displays' => $user->displays, + 'outlookAccounts' => $outlookAccounts, + 'googleAccounts' => $googleAccounts, + 'caldavAccounts' => $caldavAccounts, + 'displays' => $displays, + 'workspaces' => $workspaces, + 'selectedWorkspace' => $selectedWorkspace, '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 090888f..6902276 100644 --- a/backend/app/Http/Controllers/DisplayController.php +++ b/backend/app/Http/Controllers/DisplayController.php @@ -34,14 +34,27 @@ 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(); + $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' => $outlookAccounts, 'googleAccounts' => $googleAccounts, 'caldavAccounts' => $caldavAccounts, + 'workspaces' => $workspaces, + 'defaultWorkspace' => $selectedWorkspace ?? $user->primaryWorkspace(), ]); } @@ -55,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.'); } @@ -68,12 +81,35 @@ public function store(CreateDisplayRequest $request): RedirectResponse default => throw new \InvalidArgumentException('Invalid provider') }; - $display = DB::transaction(function () use ($validatedData) { + $user = auth()->user(); + + // 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.'); + } + + // 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 - $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 +163,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 +176,22 @@ private function createCalendar(array $validatedData): Calendar $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarId, - 'user_id' => auth()->id(), + 'workspace_id' => $workspace->id, ], [ 'calendar_id' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'name' => $calendarName, ]); Room::firstOrCreate([ 'email_address' => $calendarId, - 'user_id' => auth()->id(), + 'workspace_id' => $workspace->id, ], [ 'email_address' => $calendarId, - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, 'calendar_id' => $calendar->id, 'name' => $calendarName, ]); @@ -163,15 +202,18 @@ 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(), + 'workspace_id' => $workspace->id, ], [ - 'user_id' => auth()->id(), + 'user_id' => $userId, + 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'calendar_id' => $calendarData[0], 'name' => $calendarName, ]); + + return $calendar; } /** 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/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/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/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/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/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/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..1c267f6 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; @@ -106,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"); @@ -116,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; @@ -140,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 */ @@ -170,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'); @@ -213,4 +319,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/Models/Workspace.php b/backend/app/Models/Workspace.php new file mode 100644 index 0000000..45bd964 --- /dev/null +++ b/backend/app/Models/Workspace.php @@ -0,0 +1,132 @@ +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); + } + + /** + * 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/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..ebebe08 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,18 +24,47 @@ 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::with('user.workspaces')->find($deviceId); + + if (!$device || !$device->user_id) { + return new PermissionResult(false, 'Device not found', 404); + } + + $user = $device->user; + if (!$user) { + return new PermissionResult(false, 'User not found', 404); + } + + if (!$displayId) { + 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); } - 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); + + // Pro feature check: check if any workspace owner has Pro + if (!empty($options['pro'])) { + if (!$display->workspace->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/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_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_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_000004_create_workspaces_for_existing_users.php b/backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php new file mode 100644 index 0000000..318369b --- /dev/null +++ b/backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php @@ -0,0 +1,86 @@ +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]); + + // 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]); + } + }); + } + + /** + * 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/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/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/components/displays/table-row.blade.php b/backend/resources/views/components/displays/table-row.blade.php new file mode 100644 index 0000000..e603051 --- /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()->hasProForCurrentWorkspace()) + + + + + + + @else + + + + + + + @endif +
+ @csrf + @method('DELETE') + +
+
+ + + 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..42ff7d4 100644 --- a/backend/resources/views/layouts/base.blade.php +++ b/backend/resources/views/layouts/base.blade.php @@ -8,12 +8,12 @@ Logo Spacepad - @if(auth()->user()->hasPro()) + @if(auth()->user()->hasProForCurrentWorkspace()) Pro @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 4cf449f..f29c0bd 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

+
+
+ +
+
+ + + + + + + + + + + + + + + @forelse($allUsers as $user) + + + + + + + + + + + @empty + + + + @endforelse + +
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 + +
+ @csrf + +
+
+
+ @if(request('search')) + No users found matching "{{ request('search') }}" + @else + No users found + @endif +
+
+ @if($allUsers->hasPages()) +
+ {{ $allUsers->links('vendor.pagination.tailwind') }} +
+ @endif +
+
+
@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..3ebab16 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()) -
-
-

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 @@ -87,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())
@@ -122,7 +149,9 @@ 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(auth()->user()->hasAnyDisplay()) @@ -132,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 @@ -201,116 +230,7 @@ class="inline-flex items-center rounded-md bg-yellow-600 px-3 py-2 text-sm font- @forelse($displays as $display) - - -
{{ $display->name }}
-
{{ $display->calendar->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->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') - -
-
- - + @empty @@ -320,17 +240,18 @@ 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 @else - Create new display + + Create new display @endif
@@ -519,6 +440,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') @@ -562,6 +502,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..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 --}} @@ -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

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())
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 04d1051..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', @@ -116,6 +119,10 @@ })->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'); + 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']) 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);