diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php deleted file mode 100644 index 9cd5e470..00000000 --- a/app/Http/Controllers/BillingController.php +++ /dev/null @@ -1,150 +0,0 @@ -user(); - - // If already subscribed, redirect to billing - if ($user->subscribed('default')) { - return redirect()->route('billing.index'); - } - - return Inertia::render('billing/Subscribe', [ - 'trialDays' => config('cashier.trial_days'), - ]); - } - - /** - * Show the billing dashboard. - */ - public function index(Request $request): Response - { - $user = $request->user(); - $subscription = $user->subscription('default'); - - return Inertia::render('billing/Index', [ - 'hasSubscription' => $user->subscribed('default'), - 'onTrial' => $subscription?->onTrial() ?? false, - 'trialEndsAt' => $subscription?->trial_ends_at?->toFormattedDateString(), - 'subscription' => $subscription?->only([ - 'stripe_status', - 'quantity', - 'ends_at', - ]), - 'workspacesCount' => $user->ownedWorkspacesCount(), - 'invoices' => $user->invoices()->map(fn ($invoice) => [ - 'id' => $invoice->id, - 'date' => $invoice->date()->toFormattedDateString(), - 'total' => $invoice->total(), - 'status' => $invoice->status, - 'invoice_pdf' => $invoice->invoice_pdf, - ]), - 'defaultPaymentMethod' => $user->defaultPaymentMethod()?->card?->only([ - 'brand', - 'last4', - 'exp_month', - 'exp_year', - ]), - ]); - } - - /** - * Create a Stripe Checkout session for new subscription with trial. - */ - public function checkout(Request $request): SymfonyResponse - { - $user = $request->user(); - - $subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id')) - ->allowPromotionCodes() - ->trialDays(config('cashier.trial_days')) - ->quantity(1); - - $checkoutSession = $subscription->checkout([ - 'success_url' => route('billing.processing').'?status=success', - 'cancel_url' => route('billing.processing').'?status=cancelled', - ]); - - return Inertia::location($checkoutSession->url); - } - - /** - * Show the checkout processing page. - */ - public function processing(Request $request): Response|RedirectResponse - { - $user = $request->user(); - $status = $request->query('status', 'processing'); - - // If already subscribed, redirect to calendar - if ($user->subscribed('default')) { - return redirect()->route('calendar'); - } - - // Validate status - if (! in_array($status, ['processing', 'success', 'cancelled'])) { - $status = 'processing'; - } - - return Inertia::render('billing/Processing', [ - 'userId' => $user->id, - 'status' => $status, - ]); - } - - /** - * Redirect to Stripe Customer Portal. - */ - public function portal(Request $request): RedirectResponse - { - return $request->user()->redirectToBillingPortal( - route('billing.index') - ); - } - - /** - * Add a workspace to subscription (increment quantity). - */ - public function addWorkspace(Request $request): RedirectResponse - { - $user = $request->user(); - - if (! $user->subscribed('default')) { - return redirect()->route('billing.index') - ->withErrors(['subscription' => 'You need an active subscription.']); - } - - $user->incrementWorkspaceQuantity(); - - return back()->with('success', 'Workspace added to subscription.'); - } - - /** - * Remove a workspace from subscription (decrement quantity). - */ - public function removeWorkspace(Request $request): RedirectResponse - { - $user = $request->user(); - - if (! $user->subscribed('default')) { - return back(); - } - - $user->decrementWorkspaceQuantity(); - - return back()->with('success', 'Workspace removed from subscription.'); - } -} diff --git a/app/Http/Controllers/MediaController.php b/app/Http/Controllers/MediaController.php deleted file mode 100644 index 6b9da399..00000000 --- a/app/Http/Controllers/MediaController.php +++ /dev/null @@ -1,151 +0,0 @@ -resolveModel($request->input('model'), $request->input('model_id')); - $collection = $request->input('collection', 'default'); - - $media = $model->addMedia( - $request->file('media'), - $collection - ); - - return response()->json([ - 'id' => $media->id, - 'group_id' => $media->group_id, - 'url' => $media->url, - 'type' => $media->type->value, - 'original_filename' => $media->original_filename, - ]); - } - - public function storeChunked(StoreChunkedMediaRequest $request): JsonResponse - { - $tempFile = $this->chunkTempPath($request->chunkIdentifier()); - - $this->appendChunk($tempFile, $request->getContent(), $request->isFirstChunk()); - - if (! $request->isLastChunk()) { - return response()->json([ - 'done' => false, - 'progress' => $request->progress(), - ]); - } - - $model = $this->resolveModel($request->input('model'), $request->input('model_id')); - - $media = $model->addMediaFromPath( - $tempFile, - $request->input('file_name'), - $request->input('collection'), - ); - - @unlink($tempFile); - - return response()->json([ - 'done' => true, - 'id' => $media->id, - 'group_id' => $media->group_id, - 'url' => $media->url, - 'type' => $media->type->value, - 'original_filename' => $media->original_filename, - ]); - } - - public function destroy(string $modelId, Media $media): JsonResponse - { - if ($media->mediable_id !== $modelId) { - abort(403); - } - - $media->delete(); - - return response()->json(['success' => true]); - } - - public function reorder(Request $request): JsonResponse - { - $request->validate([ - 'media' => 'required|array', - 'media.*.id' => 'required|exists:medias,id', - 'media.*.order' => 'required|integer|min:0', - ]); - - foreach ($request->input('media') as $item) { - Media::where('id', $item['id'])->update(['order' => $item['order']]); - } - - return response()->json(['success' => true]); - } - - public function duplicate(Media $media, Request $request): JsonResponse - { - $targets = $request->input('targets', []); - - $duplicates = []; - - foreach ($targets as $target) { - $model = $this->resolveModel($target['model'], $target['model_id']); - $collection = $target['collection'] ?? $media->collection; - - $duplicate = $model->media()->create([ - 'group_id' => $media->group_id, - 'collection' => $collection, - 'type' => $media->type, - 'path' => $media->path, - 'original_filename' => $media->original_filename, - 'mime_type' => $media->mime_type, - 'size' => $media->size, - 'order' => $media->order, - 'meta' => $media->meta, - ]); - - $duplicates[] = [ - 'id' => $duplicate->id, - 'group_id' => $duplicate->group_id, - 'mediable_id' => $duplicate->mediable_id, - 'mediable_type' => $duplicate->mediable_type, - 'url' => $duplicate->url, - 'type' => $duplicate->type->value, - 'original_filename' => $duplicate->original_filename, - ]; - } - - return response()->json($duplicates); - } - - private function resolveModel(string $alias, string $id): Model - { - $modelClass = Relation::getMorphedModel($alias) ?? $alias; - - return $modelClass::findOrFail($id); - } - - private function chunkTempPath(string $identifier): string - { - return storage_path("app/private/chunks/{$identifier}"); - } - - private function appendChunk(string $path, string $content, bool $isFirst): void - { - $directory = dirname($path); - - if (! is_dir($directory)) { - mkdir($directory, 0755, true); - } - - file_put_contents($path, $content, $isFirst ? 0 : FILE_APPEND); - } -} diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php deleted file mode 100644 index 987b5f60..00000000 --- a/app/Http/Controllers/OnboardingController.php +++ /dev/null @@ -1,129 +0,0 @@ - Persona::toSelectArray(), - ]); - } - - /** - * Store step 1 and proceed to step 2. - */ - public function storeStep1(Request $request): RedirectResponse - { - $validated = $request->validate([ - 'persona' => ['required', Rule::enum(Persona::class)], - ]); - - $request->user()->update([ - 'persona' => $validated['persona'], - 'setup' => Setup::Connections, - ]); - - return redirect()->route('onboarding.step2'); - } - - /** - * Step 2: Connect social accounts. - */ - public function step2(Request $request): Response - { - $user = $request->user(); - $workspace = $user->currentWorkspace; - - $platforms = collect(); - - if ($workspace) { - $connectedAccounts = $workspace->socialAccounts; - - $platforms = collect(SocialPlatform::enabled())->map(function ($platform) use ($connectedAccounts) { - $connected = $connectedAccounts->firstWhere('platform', $platform); - - return [ - 'value' => $platform->value, - 'label' => $platform->label(), - 'color' => $platform->color(), - 'connected' => $connected !== null, - 'account' => $connected, - ]; - })->values(); - } - - return Inertia::render('onboarding/Step2', [ - 'platforms' => $platforms, - 'hasWorkspace' => $workspace !== null, - ]); - } - - /** - * Store step 2 and redirect to Stripe checkout (or complete if self-hosted). - */ - public function storeStep2(Request $request): SymfonyResponse|RedirectResponse - { - $user = $request->user(); - - // Skip payment for self-hosted mode - if (config('trypost.self_hosted')) { - $user->update([ - 'setup' => Setup::Completed, - ]); - - session()->flash('flash.banner', __('auth.flash.welcome')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('calendar'); - } - - $user->update([ - 'setup' => Setup::Subscription, - ]); - - // Redirect to Stripe checkout - $subscription = $user->newSubscription('default', config('cashier.plans.monthly.price_id')) - ->allowPromotionCodes() - ->trialDays(config('cashier.trial_days')) - ->quantity(1); - - $checkoutSession = $subscription->checkout([ - 'success_url' => route('onboarding.complete').'?session_id={CHECKOUT_SESSION_ID}', - 'cancel_url' => route('onboarding.step2'), - ]); - - return Inertia::location($checkoutSession->url); - } - - /** - * Complete onboarding after successful Stripe checkout. - */ - public function complete(Request $request): RedirectResponse - { - $user = $request->user(); - - $user->update([ - 'setup' => Setup::Completed, - ]); - - session()->flash('flash.banner', __('auth.flash.welcome_trial')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('calendar'); - } -} diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php deleted file mode 100644 index 6bc98ec1..00000000 --- a/app/Http/Controllers/PostController.php +++ /dev/null @@ -1,324 +0,0 @@ -user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - $query = $workspace->posts() - ->with(['postPlatforms' => function ($query) { - $query->where('enabled', true)->with('socialAccount'); - }, 'user', 'labels']); - - // Apply status filter if provided - if ($status) { - $query = match ($status) { - 'draft' => $query->draft(), - 'scheduled' => $query->scheduled(), - 'published' => $query->published(), - default => $query, - }; - } - - return Inertia::render('posts/Index', [ - 'workspace' => $workspace, - 'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(15)), - 'currentStatus' => $status, - ]); - } - - public function calendar(Request $request): Response|RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - $tz = $workspace->timezone; - $view = $request->input('view', 'week'); - - // Day view - $currentDay = $request->input('day') - ? Carbon::parse($request->input('day'), $tz)->startOfDay() - : Carbon::now($tz)->startOfDay(); - - // Week view - $weekStart = $request->input('week') - ? Carbon::parse($request->input('week'), $tz)->startOfWeek() - : Carbon::now($tz)->startOfWeek(); - $weekEnd = $weekStart->copy()->endOfWeek(); - - // Month view - $monthDate = $request->input('month') - ? Carbon::parse($request->input('month'), $tz)->startOfMonth() - : Carbon::now($tz)->startOfMonth(); - $monthStart = $monthDate->copy()->startOfMonth()->startOfWeek(); - $monthEnd = $monthDate->copy()->endOfMonth()->endOfWeek(); - - // Get posts based on view - $rangeStart = match ($view) { - 'day' => $currentDay, - 'month' => $monthStart, - default => $weekStart, - }; - $rangeEnd = match ($view) { - 'day' => $currentDay->copy()->endOfDay(), - 'month' => $monthEnd, - default => $weekEnd, - }; - - $posts = $workspace->posts() - ->with(['postPlatforms' => function ($query) { - $query->where('enabled', true)->with('socialAccount'); - }]) - ->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()]) - ->orderBy('scheduled_at') - ->get() - ->groupBy(fn ($post) => $post->scheduled_at?->setTimezone($tz)->format('Y-m-d')); - - return Inertia::render('posts/Calendar', [ - 'workspace' => $workspace, - 'posts' => $posts, - 'currentDay' => $currentDay->format('Y-m-d'), - 'currentWeekStart' => $weekStart->format('Y-m-d'), - 'currentMonth' => $monthDate->format('Y-m-d'), - 'view' => $view, - ]); - } - - public function store(Request $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - $socialAccounts = $workspace->socialAccounts; - - if ($socialAccounts->isEmpty()) { - session()->flash('flash.banner', __('posts.flash.connect_first')); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('accounts'); - } - - // Create a draft post - default to today if no date provided - $date = $request->input('date') ?: Carbon::now($workspace->timezone)->format('Y-m-d'); - $scheduledAt = Carbon::parse($date, $workspace->timezone) - ->setTime(9, 0) - ->utc(); - - $post = $workspace->posts()->create([ - 'user_id' => $request->user()->id, - 'status' => PostStatus::Draft, - 'synced' => true, - 'scheduled_at' => $scheduledAt, - ]); - - // Create post_platforms for each connected account - foreach ($socialAccounts as $account) { - $post->postPlatforms()->create([ - 'social_account_id' => $account->id, - 'platform' => $account->platform->value, - 'content' => '', - 'content_type' => ContentType::defaultFor($account->platform), - 'status' => 'pending', - 'enabled' => true, - ]); - } - - return Inertia::location(route('posts.edit', $post)); - } - - public function edit(Request $request, Post $post): Response|RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($post->workspace_id !== $workspace->id) { - abort(404); - } - - $post->load(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels']); - $socialAccounts = $workspace->socialAccounts; - $labels = $workspace->labels; - $hashtags = $workspace->hashtags; - - $platformConfigs = $socialAccounts->mapWithKeys(function ($account) { - $platform = $account->platform; - - return [ - $account->id => [ - 'maxContentLength' => $platform->maxContentLength(), - 'maxImages' => $platform->maxImages(), - 'allowedMediaTypes' => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes()), - 'supportsTextOnly' => $platform->supportsTextOnly(), - ], - ]; - }); - - // Fetch Pinterest boards if Pinterest account exists - $pinterestBoards = []; - $pinterestAccount = $socialAccounts->firstWhere('platform', Platform::Pinterest); - if ($pinterestAccount) { - try { - $pinterestBoards = app(PinterestPublisher::class)->getBoards($pinterestAccount); - } catch (\Exception $e) { - // Silently fail - boards will be empty - } - } - - return Inertia::render('posts/Edit', [ - 'workspace' => $workspace, - 'post' => $post, - 'socialAccounts' => $socialAccounts, - 'platformConfigs' => $platformConfigs, - 'pinterestBoards' => $pinterestBoards, - 'labels' => $labels, - 'hashtags' => $hashtags, - ]); - } - - public function update(UpdatePostRequest $request, Post $post): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($post->workspace_id !== $workspace->id) { - abort(404); - } - - if ($post->status === PostStatus::Published) { - session()->flash('flash.banner', __('posts.flash.cannot_edit_published')); - session()->flash('flash.bannerStyle', 'danger'); - - return back(); - } - - $scheduledAt = $post->scheduled_at; - if ($request->has('scheduled_at') && $request->input('scheduled_at')) { - $scheduledAt = Carbon::parse($request->input('scheduled_at'), $workspace->timezone)->utc(); - } - - $status = $request->input('status', $post->status); - - $post->update([ - 'status' => $status === 'publishing' ? PostStatus::Publishing : $status, - 'synced' => $request->input('synced', $post->synced), - 'scheduled_at' => $scheduledAt, - ]); - - // Sync labels - if ($request->has('label_ids')) { - $post->labels()->sync($request->input('label_ids', [])); - } - - // Get selected platform IDs - $selectedPlatformIds = collect($request->input('platforms', []))->pluck('id')->toArray(); - - // Update all platforms - disable those not selected, update content for selected ones - $post->postPlatforms()->update(['enabled' => false]); - - foreach ($request->input('platforms', []) as $platformData) { - $updateData = [ - 'enabled' => true, - 'content' => $platformData['content'], - 'content_type' => $platformData['content_type'] ?? null, - ]; - - if (isset($platformData['meta'])) { - $postPlatform = $post->postPlatforms()->where('id', $platformData['id'])->first(); - $updateData['meta'] = array_merge($postPlatform->meta ?? [], $platformData['meta']); - } - - $post->postPlatforms() - ->where('id', $platformData['id']) - ->update($updateData); - } - - // Dispatch publish job if publishing now - if ($status === 'publishing') { - $post->update(['scheduled_at' => now()]); - PublishPost::dispatch($post); - - session()->flash('flash.banner', __('posts.flash.publishing')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('posts.edit', $post); - } - - // Redirect to show page for schedule action - if ($status === 'scheduled') { - session()->flash('flash.banner', __('posts.flash.scheduled')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('posts.edit', $post); - } - - return back(); - } - - public function destroy(Request $request, Post $post): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($post->workspace_id !== $workspace->id) { - abort(404); - } - - $post->delete(); - - session()->flash('flash.banner', __('posts.flash.deleted')); - session()->flash('flash.bannerStyle', 'success'); - - if ($redirect = $request->input('redirect')) { - return redirect()->route($redirect); - } - - return back(); - } -} diff --git a/app/Http/Controllers/Settings/PasswordController.php b/app/Http/Controllers/Settings/PasswordController.php deleted file mode 100644 index 5f96fee0..00000000 --- a/app/Http/Controllers/Settings/PasswordController.php +++ /dev/null @@ -1,35 +0,0 @@ -user()->update([ - 'password' => $request->password, - ]); - - session()->flash('flash.banner', __('settings.flash.password_updated')); - session()->flash('flash.bannerStyle', 'success'); - - return back(); - } -} diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php deleted file mode 100644 index a4f5eb4b..00000000 --- a/app/Http/Controllers/Settings/ProfileController.php +++ /dev/null @@ -1,126 +0,0 @@ - $request->user() instanceof MustVerifyEmail, - 'status' => $request->session()->get('status'), - ]); - } - - /** - * Update the user's profile information. - */ - public function update(ProfileUpdateRequest $request): RedirectResponse - { - $request->user()->fill($request->validated()); - - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; - } - - $request->user()->save(); - - session()->flash('flash.banner', __('settings.flash.profile_updated')); - session()->flash('flash.bannerStyle', 'success'); - - return to_route('profile.edit'); - } - - /** - * Update the user's language. - */ - public function updateLanguage(Request $request): RedirectResponse - { - $request->validate([ - 'language_id' => ['required', 'exists:languages,id'], - ]); - - $request->user()->update([ - 'language_id' => $request->language_id, - ]); - - // Refresh the user model to clear cached language relationship - $request->user()->refresh(); - - session()->flash('flash.banner', __('settings.flash.language_updated')); - session()->flash('flash.bannerStyle', 'success'); - - return back(); - } - - /** - * Delete the user's profile. - */ - public function destroy(ProfileDeleteRequest $request): RedirectResponse - { - $user = $request->user(); - - DB::transaction(function () use ($user) { - // Cancel active Stripe subscription and delete subscription records - if ($user->subscribed('default')) { - $user->subscription('default')->cancelNow(); - } - $user->subscriptions()->delete(); - - // Clear current workspace reference - $user->update(['current_workspace_id' => null]); - - // Delete all workspaces owned by the user - $ownedWorkspaces = $user->workspaces()->wherePivot('role', Role::Owner->value)->get(); - - foreach ($ownedWorkspaces as $workspace) { - // Update members who have this as current_workspace_id - foreach ($workspace->members as $member) { - if ($member->id !== $user->id && $member->current_workspace_id === $workspace->id) { - // Find another workspace for this member - $otherWorkspace = $member->workspaces() - ->where('workspaces.id', '!=', $workspace->id) - ->first(); - - $member->update(['current_workspace_id' => $otherWorkspace?->id]); - } - } - - $workspace->posts()->delete(); - $workspace->socialAccounts()->delete(); - $workspace->hashtags()->delete(); - $workspace->labels()->delete(); - $workspace->invites()->delete(); - $workspace->members()->detach(); - $workspace->delete(); - } - - // Remove user from workspaces where they are a member - $user->workspaces()->detach(); - }); - - Auth::logout(); - - $user->delete(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return redirect('/'); - } -} diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/WorkspaceController.php deleted file mode 100644 index 99315d14..00000000 --- a/app/Http/Controllers/WorkspaceController.php +++ /dev/null @@ -1,169 +0,0 @@ -user(); - - $workspaces = $user->workspaces() - ->withCount(['socialAccounts', 'posts']) - ->latest() - ->get(); - - return Inertia::render('workspaces/Index', [ - 'workspaces' => $workspaces, - 'currentWorkspaceId' => $user->current_workspace_id, - ]); - } - - /** - * Show create workspace form. - */ - public function create(Request $request): Response|RedirectResponse - { - $user = $request->user(); - - // First workspace is free, subsequent ones require subscription - if ($user->ownedWorkspacesCount() > 0 && ! $user->hasActiveSubscription()) { - return redirect()->route('billing.index') - ->with('message', 'Subscribe to create more workspaces.'); - } - - return Inertia::render('workspaces/Create'); - } - - /** - * Store a new workspace. - */ - public function store(StoreWorkspaceRequest $request): RedirectResponse - { - $user = $request->user(); - - // First workspace is free, subsequent ones require subscription - if ($user->ownedWorkspacesCount() > 0 && ! $user->hasActiveSubscription()) { - return redirect()->route('billing.index') - ->with('message', 'Subscribe to create more workspaces.'); - } - - $workspace = Workspace::create([ - 'user_id' => $user->id, - ...$request->validated(), - 'timezone' => config('app.timezone', 'UTC'), - ]); - - // Add user as owner member - $workspace->members()->attach($user->id, ['role' => 'owner']); - - // Set as current workspace - $user->switchWorkspace($workspace); - - // Increment subscription quantity if user has subscription - if ($user->hasActiveSubscription()) { - $user->incrementWorkspaceQuantity(); - } - - return redirect()->route('calendar') - ->with('success', 'Workspace created successfully!'); - } - - /** - * Switch to a different workspace. - */ - public function switch(Request $request, Workspace $workspace): RedirectResponse - { - $user = $request->user(); - - if (! $user->belongsToWorkspace($workspace)) { - abort(403); - } - - $user->switchWorkspace($workspace); - - return redirect()->route('calendar'); - } - - /** - * Show workspace settings. - */ - public function settings(Request $request): Response|RedirectResponse - { - $user = $request->user(); - $workspace = $user->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('update', $workspace); - - $timezones = collect(timezone_identifiers_list()) - ->mapWithKeys(fn ($tz) => [$tz => $tz]) - ->toArray(); - - return Inertia::render('settings/Workspace', [ - 'workspace' => $workspace, - 'timezones' => $timezones, - ]); - } - - /** - * Update workspace settings. - */ - public function updateSettings(UpdateWorkspaceRequest $request): RedirectResponse - { - $user = $request->user(); - $workspace = $user->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('update', $workspace); - - $workspace->update($request->validated()); - - session()->flash('flash.banner', __('settings.flash.workspace_updated')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('workspace.settings'); - } - - /** - * Delete a workspace. - */ - public function destroy(Request $request, Workspace $workspace): RedirectResponse - { - $this->authorize('delete', $workspace); - - $user = $request->user(); - - // If deleting current workspace, clear it - if ($user->current_workspace_id === $workspace->id) { - $user->update(['current_workspace_id' => null]); - } - - $workspace->delete(); - - // Decrement subscription quantity if user has subscription - if ($user->hasActiveSubscription()) { - $user->decrementWorkspaceQuantity(); - } - - return redirect()->route('workspaces.index') - ->with('success', 'Workspace deleted successfully!'); - } -} diff --git a/app/Http/Controllers/WorkspaceHashtagController.php b/app/Http/Controllers/WorkspaceHashtagController.php deleted file mode 100644 index 6dd79d52..00000000 --- a/app/Http/Controllers/WorkspaceHashtagController.php +++ /dev/null @@ -1,100 +0,0 @@ -user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - return Inertia::render('hashtags/Index', [ - 'workspace' => $workspace, - 'hashtags' => $workspace->hashtags()->latest()->get(), - ]); - } - - public function store(Request $request): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'hashtags' => ['required', 'string'], - ]); - - $workspace->hashtags()->create($validated); - - session()->flash('flash.banner', __('hashtags.flash.created')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('hashtags.index'); - } - - public function update(Request $request, WorkspaceHashtag $hashtag): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($hashtag->workspace_id !== $workspace->id) { - abort(404); - } - - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'hashtags' => ['required', 'string'], - ]); - - $hashtag->update($validated); - - session()->flash('flash.banner', __('hashtags.flash.updated')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('hashtags.index'); - } - - public function destroy(Request $request, WorkspaceHashtag $hashtag): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($hashtag->workspace_id !== $workspace->id) { - abort(404); - } - - $hashtag->delete(); - - session()->flash('flash.banner', __('hashtags.flash.deleted')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('hashtags.index'); - } -} diff --git a/app/Http/Controllers/WorkspaceInviteController.php b/app/Http/Controllers/WorkspaceInviteController.php deleted file mode 100644 index ab891eba..00000000 --- a/app/Http/Controllers/WorkspaceInviteController.php +++ /dev/null @@ -1,140 +0,0 @@ -user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('manageTeam', $workspace); - - return Inertia::render('settings/Members', [ - 'workspace' => $workspace, - 'invites' => $workspace->invites() - ->latest() - ->get(), - 'members' => $workspace->members() - ->where('user_id', '!=', $workspace->user_id) - ->get() - ->map(fn ($member) => [ - 'id' => $member->id, - 'name' => $member->name, - 'email' => $member->email, - 'role' => $member->pivot->role, - ]), - 'owner' => [ - 'id' => $workspace->owner->id, - 'name' => $workspace->owner->name, - 'email' => $workspace->owner->email, - 'role' => WorkspaceRole::Owner->value, - ], - 'roles' => collect(WorkspaceRole::cases()) - ->filter(fn ($role) => $role !== WorkspaceRole::Owner) - ->map(fn ($role) => [ - 'value' => $role->value, - 'label' => $role->label(), - ])->values(), - ]); - } - - public function store(StoreWorkspaceInviteRequest $request): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('manageTeam', $workspace); - - $existingInvite = $workspace->invites() - ->where('email', $request->email) - ->first(); - - if ($existingInvite) { - return back()->withErrors([ - 'email' => 'An invite already exists for this email.', - ]); - } - - if ($workspace->members()->where('email', $request->email)->exists()) { - return back()->withErrors([ - 'email' => 'This user is already a member of the workspace.', - ]); - } - - $invite = $workspace->invites()->create([ - 'email' => $request->email, - 'role' => $request->role ?? WorkspaceRole::Member, - ]); - - Mail::to($invite->email)->send(new WorkspaceInviteMail($invite)); - - session()->flash('flash.banner', __('settings.members.flash.invite_sent')); - session()->flash('flash.bannerStyle', 'success'); - - return back(); - } - - public function destroy(Request $request, WorkspaceInvite $invite): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('manageTeam', $workspace); - - if ($invite->workspace_id !== $workspace->id) { - abort(404); - } - - $invite->delete(); - - session()->flash('flash.banner', __('settings.members.flash.invite_deleted')); - session()->flash('flash.bannerStyle', 'success'); - - return back(); - } - - public function removeMember(Request $request, string $userId): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('manageTeam', $workspace); - - if ($workspace->user_id === $userId) { - return back()->withErrors(['member' => 'Cannot remove the workspace owner.']); - } - - $workspace->members()->detach($userId); - - session()->flash('flash.banner', __('settings.members.flash.member_removed')); - session()->flash('flash.bannerStyle', 'success'); - - return back(); - } -} diff --git a/app/Http/Controllers/WorkspaceLabelController.php b/app/Http/Controllers/WorkspaceLabelController.php deleted file mode 100644 index 01b56e58..00000000 --- a/app/Http/Controllers/WorkspaceLabelController.php +++ /dev/null @@ -1,100 +0,0 @@ -user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - return Inertia::render('labels/Index', [ - 'workspace' => $workspace, - 'labels' => $workspace->labels()->latest()->get(), - ]); - } - - public function store(Request $request): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], - ]); - - $workspace->labels()->create($validated); - - session()->flash('flash.banner', __('labels.flash.created')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('labels.index'); - } - - public function update(Request $request, WorkspaceLabel $label): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($label->workspace_id !== $workspace->id) { - abort(404); - } - - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], - ]); - - $label->update($validated); - - session()->flash('flash.banner', __('labels.flash.updated')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('labels.index'); - } - - public function destroy(Request $request, WorkspaceLabel $label): RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($label->workspace_id !== $workspace->id) { - abort(404); - } - - $label->delete(); - - session()->flash('flash.banner', __('labels.flash.deleted')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('labels.index'); - } -} diff --git a/bootstrap/app.php b/bootstrap/app.php index 9d575680..964e9a96 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -19,6 +19,8 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->trustProxies(at: '*'); + $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->web(append: [ @@ -32,7 +34,7 @@ 'api.auth' => AuthenticateApiToken::class, ]); - $middleware->validateCsrfTokens(except: [ + $middleware->preventRequestForgery(except: [ 'stripe/*', ]); }) diff --git a/database/factories/ApiTokenFactory.php b/database/factories/ApiTokenFactory.php new file mode 100644 index 00000000..cc7cde5a --- /dev/null +++ b/database/factories/ApiTokenFactory.php @@ -0,0 +1,50 @@ + + */ +class ApiTokenFactory extends Factory +{ + /** + * The plain token generated during creation. + */ + public static ?string $lastPlainToken = null; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $plainToken = 'tp_'.Str::random(48); + static::$lastPlainToken = $plainToken; + + return [ + 'workspace_id' => Workspace::factory(), + 'name' => fake()->words(2, true), + 'token_lookup' => substr($plainToken, 3, 16), + 'token_hash' => Hash::make($plainToken), + 'last_used_at' => null, + 'expires_at' => null, + ]; + } + + /** + * Mark the token as expired. + */ + public function expired(): static + { + return $this->state(fn () => [ + 'expires_at' => now()->subDay(), + ]); + } +} diff --git a/resources/js/layouts/settings/Layout.vue b/resources/js/layouts/settings/Layout.vue index 2056aa15..e1b5e66d 100644 --- a/resources/js/layouts/settings/Layout.vue +++ b/resources/js/layouts/settings/Layout.vue @@ -7,6 +7,7 @@ import Heading from '@/components/Heading.vue'; import { useActiveUrl } from '@/composables/useActiveUrl'; import { toUrl } from '@/lib/utils'; import { members } from '@/routes/app'; +import { index as apiKeys } from '@/routes/app/api-keys'; import { index as billing } from '@/routes/app/billing'; import { edit as editProfile } from '@/routes/app/profile'; import { edit as editPassword } from '@/routes/app/user-password'; @@ -39,6 +40,10 @@ const navItems = computed(() => { title: trans('settings.nav.members'), href: members(), }, + { + title: 'API Keys', + href: apiKeys(), + }, ); if (!page.props.selfHosted) { diff --git a/resources/js/pages/settings/ApiKeys.vue b/resources/js/pages/settings/ApiKeys.vue new file mode 100644 index 00000000..08ee04b6 --- /dev/null +++ b/resources/js/pages/settings/ApiKeys.vue @@ -0,0 +1,267 @@ + + + diff --git a/routes/mcp.php b/routes/mcp.php new file mode 100644 index 00000000..92f6d3b7 --- /dev/null +++ b/routes/mcp.php @@ -0,0 +1,14 @@ + 'mcp.'.parse_url(config('app.url'), PHP_URL_HOST), + ], + function () { + // + } +); diff --git a/routes/settings.php b/routes/settings.php deleted file mode 100644 index 07fd5a62..00000000 --- a/routes/settings.php +++ /dev/null @@ -1,21 +0,0 @@ -group(function () { - Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit'); - Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); - Route::patch('settings/language', [ProfileController::class, 'updateLanguage'])->name('profile.language'); -}); - -Route::middleware(['auth', 'verified'])->group(function () { - Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); - - Route::get('settings/password', [PasswordController::class, 'edit'])->name('user-password.edit'); - - Route::put('settings/password', [PasswordController::class, 'update']) - ->middleware('throttle:6,1') - ->name('user-password.update'); -}); diff --git a/tests/Feature/Api/ApiKeyApiTest.php b/tests/Feature/Api/ApiKeyApiTest.php new file mode 100644 index 00000000..c9731997 --- /dev/null +++ b/tests/Feature/Api/ApiKeyApiTest.php @@ -0,0 +1,127 @@ +create(); + + $factoryOverrides = collect($overrides)->except('workspace')->toArray(); + + $apiToken = ApiToken::factory()->create(array_merge([ + 'workspace_id' => $workspace->id, + 'token_lookup' => substr($plainToken, 3, 16), + 'token_hash' => Hash::make($plainToken), + ], $factoryOverrides)); + + return [ + 'token' => $apiToken, + 'plain_token' => $plainToken, + 'workspace' => $workspace, + ]; +} + +test('list api keys', function () { + $result = createApiKeyApiToken(); + + // The authenticating token itself is one, create two more + ApiToken::factory()->count(2)->create([ + 'workspace_id' => $result['workspace']->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->getJson( + route('api.api-keys.index'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonCount(3); +}); + +test('create api key returns plain token', function () { + $result = createApiKeyApiToken(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->postJson( + route('api.api-keys.store'), + [ + 'name' => 'CI/CD Token', + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertCreated(); + $response->assertJsonStructure([ + 'token' => ['id', 'name', 'key_hint', 'status'], + 'plain_token', + ]); + + $plainToken = $response->json('plain_token'); + expect($plainToken)->toStartWith('tp_'); + expect(strlen($plainToken))->toBe(51); +}); + +test('create api key validation errors', function () { + $result = createApiKeyApiToken(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->postJson( + route('api.api-keys.store'), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['name']); +}); + +test('delete api key', function () { + $result = createApiKeyApiToken(); + + $tokenToDelete = ApiToken::factory()->create([ + 'workspace_id' => $result['workspace']->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->deleteJson( + route('api.api-keys.destroy', $tokenToDelete), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertNoContent(); + + expect(ApiToken::find($tokenToDelete->id))->toBeNull(); +}); + +test('cannot delete api key from another workspace', function () { + $result = createApiKeyApiToken(); + + $otherWorkspace = Workspace::factory()->create(); + $otherToken = ApiToken::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->deleteJson( + route('api.api-keys.destroy', $otherToken), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Api/AuthenticateApiTokenTest.php b/tests/Feature/Api/AuthenticateApiTokenTest.php new file mode 100644 index 00000000..40a17844 --- /dev/null +++ b/tests/Feature/Api/AuthenticateApiTokenTest.php @@ -0,0 +1,117 @@ +create(); + + $factoryOverrides = collect($overrides)->except('workspace')->toArray(); + + $apiToken = ApiToken::factory()->create(array_merge([ + 'workspace_id' => $workspace->id, + 'token_lookup' => substr($plainToken, 3, 16), + 'token_hash' => Hash::make($plainToken), + ], $factoryOverrides)); + + return [ + 'token' => $apiToken, + 'plain_token' => $plainToken, + 'workspace' => $workspace, + ]; +} + +test('returns 401 without token', function () { + $response = $this->getJson( + route('api.workspace.show'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnauthorized(); + $response->assertJson(['message' => 'Missing API key.']); +}); + +test('returns 401 with invalid token format', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer invalid-token', + ])->getJson( + route('api.workspace.show'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnauthorized(); + $response->assertJson(['message' => 'Invalid API key.']); +}); + +test('returns 401 with wrong token', function () { + createApiToken(); + + $wrongToken = 'tp_'.Str::random(48); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$wrongToken, + ])->getJson( + route('api.workspace.show'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnauthorized(); + $response->assertJson(['message' => 'Invalid API key.']); +}); + +test('returns 401 with expired token', function () { + $result = createApiToken(); + + $result['token']->update(['expires_at' => now()->subDay()]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->getJson( + route('api.workspace.show'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnauthorized(); + $response->assertJson(['message' => 'API key has expired.']); +}); + +test('authenticates with valid token', function () { + $result = createApiToken(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->getJson( + route('api.workspace.show'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); +}); + +test('updates last_used_at on successful auth', function () { + $this->freezeTime(); + + $result = createApiToken(); + + expect($result['token']->last_used_at)->toBeNull(); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->getJson( + route('api.workspace.show'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $result['token']->refresh(); + + expect($result['token']->last_used_at)->not->toBeNull(); + expect($result['token']->last_used_at->toDateTimeString())->toBe(now()->toDateTimeString()); +}); diff --git a/tests/Feature/Api/HashtagApiTest.php b/tests/Feature/Api/HashtagApiTest.php new file mode 100644 index 00000000..af2fffc5 --- /dev/null +++ b/tests/Feature/Api/HashtagApiTest.php @@ -0,0 +1,149 @@ +create(); + + $factoryOverrides = collect($overrides)->except('workspace')->toArray(); + + $apiToken = ApiToken::factory()->create(array_merge([ + 'workspace_id' => $workspace->id, + 'token_lookup' => substr($plainToken, 3, 16), + 'token_hash' => Hash::make($plainToken), + ], $factoryOverrides)); + + return [ + 'token' => $apiToken, + 'plain_token' => $plainToken, + 'workspace' => $workspace, + ]; +} + +test('list hashtags', function () { + $result = createHashtagApiToken(); + + WorkspaceHashtag::factory()->count(3)->create([ + 'workspace_id' => $result['workspace']->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->getJson( + route('api.hashtags.index'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonCount(3); +}); + +test('create hashtag', function () { + $result = createHashtagApiToken(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->postJson( + route('api.hashtags.store'), + [ + 'name' => 'Marketing Tags', + 'hashtags' => '#marketing #growth #saas', + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertCreated(); + $response->assertJsonPath('name', 'Marketing Tags'); + + expect($result['workspace']->hashtags()->count())->toBe(1); +}); + +test('create hashtag validation errors', function () { + $result = createHashtagApiToken(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->postJson( + route('api.hashtags.store'), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['name', 'hashtags']); +}); + +test('update hashtag', function () { + $result = createHashtagApiToken(); + + $hashtag = WorkspaceHashtag::factory()->create([ + 'workspace_id' => $result['workspace']->id, + 'name' => 'Old Name', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->putJson( + route('api.hashtags.update', $hashtag), + [ + 'name' => 'Updated Name', + 'hashtags' => '#updated #tags', + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonPath('name', 'Updated Name'); +}); + +test('delete hashtag', function () { + $result = createHashtagApiToken(); + + $hashtag = WorkspaceHashtag::factory()->create([ + 'workspace_id' => $result['workspace']->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->deleteJson( + route('api.hashtags.destroy', $hashtag), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertNoContent(); + + expect(WorkspaceHashtag::find($hashtag->id))->toBeNull(); +}); + +test('cannot access hashtags from another workspace', function () { + $result = createHashtagApiToken(); + + $otherWorkspace = Workspace::factory()->create(); + $hashtag = WorkspaceHashtag::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->putJson( + route('api.hashtags.update', $hashtag), + [ + 'name' => 'Hacked Name', + 'hashtags' => '#hacked', + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Api/LabelApiTest.php b/tests/Feature/Api/LabelApiTest.php new file mode 100644 index 00000000..985611b6 --- /dev/null +++ b/tests/Feature/Api/LabelApiTest.php @@ -0,0 +1,170 @@ +create(); + + $factoryOverrides = collect($overrides)->except('workspace')->toArray(); + + $apiToken = ApiToken::factory()->create(array_merge([ + 'workspace_id' => $workspace->id, + 'token_lookup' => substr($plainToken, 3, 16), + 'token_hash' => Hash::make($plainToken), + ], $factoryOverrides)); + + return [ + 'token' => $apiToken, + 'plain_token' => $plainToken, + 'workspace' => $workspace, + ]; +} + +test('list labels', function () { + $result = createLabelApiToken(); + + WorkspaceLabel::factory()->count(3)->create([ + 'workspace_id' => $result['workspace']->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->getJson( + route('api.labels.index'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonCount(3); +}); + +test('create label', function () { + $result = createLabelApiToken(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->postJson( + route('api.labels.store'), + [ + 'name' => 'Marketing', + 'color' => '#FF0000', + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertCreated(); + $response->assertJsonPath('name', 'Marketing'); + $response->assertJsonPath('color', '#FF0000'); + + expect($result['workspace']->labels()->count())->toBe(1); +}); + +test('create label validation errors', function () { + $result = createLabelApiToken(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->postJson( + route('api.labels.store'), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['name', 'color']); +}); + +test('create label validates color format', function () { + $result = createLabelApiToken(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->postJson( + route('api.labels.store'), + [ + 'name' => 'Bad Color', + 'color' => 'not-a-color', + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['color']); +}); + +test('update label', function () { + $result = createLabelApiToken(); + + $label = WorkspaceLabel::factory()->create([ + 'workspace_id' => $result['workspace']->id, + 'name' => 'Old Name', + 'color' => '#000000', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->putJson( + route('api.labels.update', $label), + [ + 'name' => 'Updated Name', + 'color' => '#FFFFFF', + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonPath('name', 'Updated Name'); + $response->assertJsonPath('color', '#FFFFFF'); +}); + +test('delete label', function () { + $result = createLabelApiToken(); + + $label = WorkspaceLabel::factory()->create([ + 'workspace_id' => $result['workspace']->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->deleteJson( + route('api.labels.destroy', $label), + [], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertNoContent(); + + expect(WorkspaceLabel::find($label->id))->toBeNull(); +}); + +test('cannot access labels from another workspace', function () { + $result = createLabelApiToken(); + + $otherWorkspace = Workspace::factory()->create(); + $label = WorkspaceLabel::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->putJson( + route('api.labels.update', $label), + [ + 'name' => 'Hacked Name', + 'color' => '#FF0000', + ], + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Api/WorkspaceApiTest.php b/tests/Feature/Api/WorkspaceApiTest.php new file mode 100644 index 00000000..13713638 --- /dev/null +++ b/tests/Feature/Api/WorkspaceApiTest.php @@ -0,0 +1,57 @@ +create(); + + $factoryOverrides = collect($overrides)->except('workspace')->toArray(); + + $apiToken = ApiToken::factory()->create(array_merge([ + 'workspace_id' => $workspace->id, + 'token_lookup' => substr($plainToken, 3, 16), + 'token_hash' => Hash::make($plainToken), + ], $factoryOverrides)); + + return [ + 'token' => $apiToken, + 'plain_token' => $plainToken, + 'workspace' => $workspace, + ]; +} + +test('show current workspace', function () { + $workspace = Workspace::factory()->create([ + 'name' => 'Test Workspace', + ]); + + $result = createWorkspaceApiToken(['workspace' => $workspace]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$result['plain_token'], + ])->getJson( + route('api.workspace.show'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertOk(); + $response->assertJsonPath('name', 'Test Workspace'); +}); + +test('show workspace requires authentication', function () { + $response = $this->getJson( + route('api.workspace.show'), + ['HTTP_HOST' => 'api.trypost.test'] + ); + + $response->assertUnauthorized(); +});