Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
00c48fc
Add post-subscription onboarding design spec.
paulocastellano Jul 24, 2026
5b8e6ff
Add post-subscription onboarding implementation plan.
paulocastellano Jul 24, 2026
44fb9be
Add account onboarding completed and dismissed timestamps.
paulocastellano Jul 24, 2026
a69b799
Add ResolveOnboardingStatus for activation checklist state.
paulocastellano Jul 24, 2026
abf247e
Fix ResolveOnboardingStatus null-account guard and workspace scoping …
paulocastellano Jul 24, 2026
7655c1f
Rename pre-subscription ICP flow to Welcome routes.
paulocastellano Jul 24, 2026
7786204
Fix Task 3 review: rename SocialController welcome test label.
paulocastellano Jul 24, 2026
9827714
Add Welcome Vue pages and i18n; drop social from subscribe.
paulocastellano Jul 24, 2026
1c13007
Add post-subscription onboarding activation endpoints.
paulocastellano Jul 24, 2026
ea615a6
Add single-page post-subscription onboarding UI.
paulocastellano Jul 24, 2026
4726204
Send post-checkout users to onboarding instead of accounts.
paulocastellano Jul 24, 2026
ec64768
Add dismissible residual onboarding banner in app shell.
paulocastellano Jul 24, 2026
ff462e5
Track welcome and onboarding activation events in PostHog.
paulocastellano Jul 24, 2026
254e7b4
Fix post-subscription onboarding review findings.
paulocastellano Jul 24, 2026
fb7a00b
Remove welcome subscribe step and start Stripe from referral.
paulocastellano Jul 24, 2026
5dd4983
Use AppLayout with sidebar on the onboarding activation page.
paulocastellano Jul 24, 2026
8659da8
Polish welcome and onboarding UX and unify the sidebar account menu.
paulocastellano Jul 24, 2026
a1254b4
Refactor billing configuration and subscription checkout process.
paulocastellano Jul 26, 2026
0d75b54
Polish onboarding realtime updates and tighten activation code.
paulocastellano Jul 26, 2026
efc902f
Localize onboarding and welcome strings across all locales.
paulocastellano Jul 26, 2026
996310c
Translate remaining welcome progress labels across locales.
paulocastellano Jul 26, 2026
54b57f2
Add workspace MCP settings page and polish sidebar nav.
paulocastellano Jul 26, 2026
a7f0865
Align MCP settings cards with TryPost neobrutalist UI.
paulocastellano Jul 26, 2026
64354fb
Make ChatGPT MCP icon use currentColor for dark tiles.
paulocastellano Jul 26, 2026
de12c16
Fix MCP accordion cards clipping their bottom border.
paulocastellano Jul 26, 2026
5260f4e
Match Claude and ChatGPT MCP connect cards to onboarding.
paulocastellano Jul 26, 2026
0f19048
Remove open-state border under MCP accordion titles.
paulocastellano Jul 26, 2026
5d39ff0
Give open MCP accordion content a bit more top spacing.
paulocastellano Jul 26, 2026
74d558d
Add MCP link to the workspace section of the sidebar.
paulocastellano Jul 26, 2026
c423fcf
Point MCP settings docs link to the AI introduction page.
paulocastellano Jul 26, 2026
f3df17b
Drop the orphaned step number in other MCP client cards.
paulocastellano Jul 26, 2026
b7d427f
Keep Settings inactive in the sidebar on the MCP page.
paulocastellano Jul 26, 2026
b93153f
Tighten MCP settings copy and match HeadingSmall hierarchy.
paulocastellano Jul 26, 2026
a626edc
Localize MCP settings copy across all supported languages.
paulocastellano Jul 26, 2026
75f5017
Use a white background for the Cursor MCP client tile.
paulocastellano Jul 26, 2026
147b3bf
Harden onboarding MCP detection and post-checkout redirects.
paulocastellano Jul 26, 2026
0af1bae
Align account-scoped MCP onboarding and name the MCP route.
paulocastellano Jul 26, 2026
8455441
Broadcast MCP onboarding account-wide and list team connections.
paulocastellano Jul 26, 2026
5bd5099
Attribute onboarding analytics to the acting user and sync only them.
paulocastellano Jul 26, 2026
57701d5
Harden post-subscription onboarding edge cases from review.
cursoragent Jul 29, 2026
176ee41
Close remaining onboarding review follow-ups.
cursoragent Jul 29, 2026
ca2d350
Harden onboarding against transaction side effects and access gaps.
cursoragent Jul 29, 2026
0a7a590
Align MCP/API workspace access with hasAppAccess.
cursoragent Jul 29, 2026
8a46a5e
Broadcast onboarding complete/dismiss to clear residual immediately.
cursoragent Jul 29, 2026
9db85d8
Clear residual and sync sibling Echo on every onboarding stamp.
cursoragent Jul 29, 2026
6d470bb
Harden onboarding dismiss terminality and avoid double navigation.
cursoragent Jul 29, 2026
90bc604
Redirect completed onboarding revisits and fix residual progress.
cursoragent Jul 29, 2026
b6c72f0
Keep celebration after OAuth stamps completion via session flash.
cursoragent Jul 29, 2026
c833032
Prevent poll/Echo from consuming onboarding celebration flash.
cursoragent Jul 29, 2026
739e198
Polish checkout trial typing and align onboarding account scope.
cursoragent Jul 29, 2026
d36b7c0
Remove dead tryMarkAccountComplete and cover incomplete_expired trial.
cursoragent Jul 29, 2026
2dbabca
Merge origin/main into post-subscription onboarding.
cursoragent Jul 29, 2026
c8e183c
Merge origin/main into post-subscription onboarding.
paulocastellano Aug 1, 2026
fe8a849
Eager-load subscriptions in onboarding dismiss backfill.
paulocastellano Aug 1, 2026
9fc46ca
Refactor MCP client handling in onboarding and settings pages
paulocastellano Aug 1, 2026
7c8aaff
Refactor onboarding logic in AppSidebar and related components
paulocastellano Aug 1, 2026
0208ce1
Keep onboarding schema migration schema-only.
cursoragent Aug 1, 2026
2e7cab9
Fix onboarding review findings: perf, races, UX and a11y.
paulocastellano Aug 1, 2026
82bda00
Scope TrackPostTest owner-fallback assertion to the post.created event.
paulocastellano Aug 1, 2026
70bcf85
Hold members without app access on a dedicated screen; add funnel bro…
paulocastellano Aug 1, 2026
5a6783b
Update browser-test rules to data-testid selectors and in-process ser…
paulocastellano Aug 1, 2026
5208a3c
Polish remaining review items: mobile residual, analytics timing, con…
paulocastellano Aug 1, 2026
1e049a1
Fix celebration session flag and polish access redirects.
cursoragent Aug 2, 2026
13a6eba
Verify checkout sessions before tracking, fix mobile residual layout.
paulocastellano Aug 2, 2026
d51a255
Gate purchase tracking on verified conversion; keep mobile residual s…
paulocastellano Aug 2, 2026
41da56f
Clear stale celebration flags on Continue and drop a dead prop.
paulocastellano Aug 2, 2026
5ab3e1b
Drop missing api_keys.copy_success i18n key from the token copy action.
paulocastellano Aug 2, 2026
4ac56cc
Harden purchase tracking, slow residual fallback polls, UX/a11y polish.
cursoragent Aug 2, 2026
9fea152
Guard localStorage writes, neutral success copy, translation fixes.
cursoragent Aug 2, 2026
ebb7077
Remove the logout link from the welcome flow screens.
paulocastellano Aug 3, 2026
f7f6269
Drop the plan/trial note from the referral checkout step.
paulocastellano Aug 3, 2026
8c55508
Remove the dismiss X from the onboarding residual banners.
paulocastellano Aug 3, 2026
0b1fd1b
Replace the global onboarding skip with per-step skip for optional st…
paulocastellano Aug 3, 2026
6ea43a9
Fold the skipped-steps column into the onboarding schema migration.
paulocastellano Aug 3, 2026
1033ba3
Harden purchase tracking retries and guard onboarding timestamps.
paulocastellano Aug 3, 2026
e41552d
Wait for checkout verification before finishing purchase tracking.
paulocastellano Aug 3, 2026
f42dbf7
Re-deliver checkout conversion until ack and escape stuck processing.
paulocastellano Aug 3, 2026
7bb9562
Persist checkout tracking and harden onboarding compatibility.
paulocastellano Aug 3, 2026
7371f0b
Use a 24-hour cache for checkout tracking.
paulocastellano Aug 3, 2026
95fd2b1
Remove obsolete onboarding compatibility routes.
paulocastellano Aug 3, 2026
39913b4
Remove the redundant mobile onboarding residual.
paulocastellano Aug 3, 2026
be33689
Centralize Stripe checkout session classification.
paulocastellano Aug 3, 2026
82e1e8a
Simplify onboarding status and MCP setup.
paulocastellano Aug 3, 2026
656d700
Harden MCP and onboarding permissions.
paulocastellano Aug 3, 2026
06186c6
Harden MCP authorization and onboarding flows.
cursoragent Aug 3, 2026
892807b
Restrict REST API to personal access tokens.
cursoragent Aug 3, 2026
eb81fe2
Harden self-hosted API and MCP access.
cursoragent Aug 3, 2026
0191ded
Harden token lifecycle and self-hosted deployment.
cursoragent Aug 3, 2026
aabe84a
Make checkout conversion handling idempotent.
cursoragent Aug 3, 2026
9e793b6
Strengthen onboarding frontend quality checks.
cursoragent Aug 3, 2026
5264ec9
Fix self-hosted key setup and code style.
cursoragent Aug 3, 2026
112ff48
Fix frontend type contracts.
cursoragent Aug 4, 2026
90ee1fb
Stabilize checkout and analytics regression tests.
cursoragent Aug 4, 2026
70a9e5f
Stabilize checkout completion and idempotency tests.
cursoragent Aug 4, 2026
283254f
Guarantee post-checkout navigation.
cursoragent Aug 4, 2026
63095c9
Avoid checkout polling during conversion delivery.
cursoragent Aug 4, 2026
68db187
Await checkout acknowledgement before redirecting.
cursoragent Aug 4, 2026
f07f610
Observe full checkout navigation in browser tests.
cursoragent Aug 4, 2026
0faf1be
Use a complete workspace fixture for checkout navigation.
cursoragent Aug 4, 2026
1814ef0
Harden checkout concurrency and recovery.
cursoragent Aug 4, 2026
33a0e25
Enforce social network uniqueness atomically.
cursoragent Aug 4, 2026
51212c2
Harden API key and OAuth lifecycle controls.
cursoragent Aug 4, 2026
a5eb1a6
Match OAuth registration rejection response.
cursoragent Aug 4, 2026
92f31d3
Harden post-review merge blockers for checkout and tokens.
paulocastellano Aug 4, 2026
2ab3700
Enable conditional seeding of PlanSeeder based on self-hosted configu…
paulocastellano Aug 4, 2026
4a4d6d8
Make API key expiration optional like Sendkit.
paulocastellano Aug 4, 2026
0a4d695
Cover optional API key expiration across web, REST, and MCP.
paulocastellano Aug 4, 2026
dbd63d3
Always lint the full project in CI.
paulocastellano Aug 4, 2026
75c0359
Add workspace settings to sidebar translations and update DatabaseSee…
paulocastellano Aug 4, 2026
0b64f9c
Refactor MCP OAuth grant revocation logic in RevokeMcpOAuthGrants and…
paulocastellano Aug 4, 2026
b99fc2c
Refactor access token revocation in member removal and role update pr…
paulocastellano Aug 4, 2026
e94e5da
Refactor response handling in controllers to use InertiaResponse
paulocastellano Aug 4, 2026
a4aa532
Add Passport OAuth key configuration to environment files
paulocastellano Aug 4, 2026
f4b1642
Refactor PostHog event handling and deduplication logic
paulocastellano Aug 4, 2026
a7f3aa2
Enhance onboarding and billing processes with improved logic and cleanup
paulocastellano Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 3 additions & 3 deletions .claude/release-assets/render-thumbnail.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
// Playwright is resolved from the repo's node_modules, so the script works
// regardless of where it is invoked.

import { createRequire } from 'module';
import { readFileSync, writeFileSync, unlinkSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { createRequire } from 'module';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';

const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
Expand Down
38 changes: 23 additions & 15 deletions .cursor/rules/tests-dusk.mdc
Original file line number Diff line number Diff line change
@@ -1,43 +1,51 @@
---
description: Laravel Dusk browser tests — named routes, dusk selectors, no CSS/text-based assertions
description: Browser tests (Pest + Playwright) — named routes, data-testid selectors, no CSS/text-based assertions
globs: tests/Browser/**/*.php
alwaysApply: false
---

# Dusk Browser Tests
# Browser Tests

## Named routes (IMPORTANT)

ALWAYS use named routes via the `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`.

```php
// BAD
$browser->visit('https://trypost.test/login');
visit('https://trypost.test/login');

// GOOD
$browser->visit(route('login'));
visit(route('login'));
```

## Dusk selectors
## Selectors (IMPORTANT)

ALWAYS use `dusk` selectors (`@selector-name`) for interactions and assertions. NEVER use CSS classes (`.text-red-600`), tag names, or text strings.
ALWAYS use `data-testid` attributes and target them with `@selector-name`. NEVER use CSS classes (`.text-red-600`), tag names, or text strings.

Add `dusk="my-element"` attributes to Vue components and target them with `$browser->click('@my-element')`, `$browser->waitFor('@my-element')`, etc.
The Pest browser plugin resolves `@selector` against `[data-testid]` / `[data-test]` only — `dusk="..."` attributes are NOT matched (legacy from Laravel Dusk).

Add `data-testid="my-element"` attributes to Vue components and target them with `$page->click('@my-element')`, `$page->assertVisible('@my-element')`, etc.

```php
// BAD
$browser->waitFor('.text-red-600');
$browser->click('button.primary');
$browser->assertSee('Welcome');
$page->assertVisible('.text-red-600');
$page->click('button.primary');

// GOOD
$browser->waitFor('@input-error');
$browser->click('@submit-button');
$browser->assertVisible('@welcome-message');
$page->assertVisible('@input-error');
$page->click('@submit-button');
```

```vue
<!-- Vue component -->
<button dusk="submit-button" @click="handleSubmit">Submit</button>
<span dusk="input-error" v-if="error">{{ error }}</span>
<button data-testid="submit-button" @click="handleSubmit">Submit</button>
<span data-testid="input-error" v-if="error">{{ error }}</span>
```

## No auto-wait

Browser assertions do not auto-wait. Poll browser-side (see `waitForTestId` / `waitForDusk` helpers in existing tests) before asserting on async UI.

## Strict models

Use `->fresh()` when acting as a factory-created user — the in-process server keeps the guard user across requests, and factory instances lack nullable columns (strict mode throws `MissingAttributeException`).
21 changes: 17 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ WEBHOOK_URL=
# Self-hosted mode (skips payment requirements)
SELF_HOSTED=true

# Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so
# every node behind a load balancer shares the same key pair. Use literal \n
# for newlines in the PEM. When unset, Passport falls back to storage/oauth-*.key
# (generate with: php artisan passport:keys).
# PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"

TELESCOPE_ENABLED=false

APP_LOCALE=en
Expand Down Expand Up @@ -191,11 +198,17 @@ STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Set to false to allow generic signup trial without requiring a card.
REQUIRE_CARD_FOR_TRIAL=true
# Free trial length in days, used only for the no-card generic trial above.
# Checkout trial length in days (card collected up front when REQUIRE_CARD_FOR_TRIAL=true).
# - >0: Stripe Checkout trial for N days for first-time signups only (no prior
# real subscription). Values below 2 are clamped to 2 (Stripe's 48h min).
# - 0: no Checkout trial; charge starts immediately.
# - Returning / canceled accounts never get another Checkout trial.
# - When REQUIRE_CARD_FOR_TRIAL=false, Checkout never applies a trial (generic
# signup trial uses this value instead; must be >= 1 in that mode).
# Empty = default 8.
CASHIER_TRIAL_DAYS=8
# Stripe Coupon ID (amount_off, duration=once) so the first invoice at
# checkout comes out to $1 instead of the full monthly price.
STRIPE_FIRST_MONTH_COUPON_ID=
# Show Stripe Checkout promotion-code field.
CASHIER_ALLOW_PROMOTION_CODES=true

# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
STRIPE_WORKSPACE_MONTHLY=
Expand Down
23 changes: 7 additions & 16 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: linter
name: lints

on:
push:
Expand All @@ -10,14 +10,11 @@ on:
- develop
- main

permissions:
contents: write

jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand All @@ -29,17 +26,11 @@ jobs:
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install

- name: Run Pint
run: composer lint
- name: Check PHP Formatting
run: composer test:lint

- name: Format Frontend
run: npm run format
- name: Check Frontend Formatting
run: npm run format:check

- name: Lint Frontend
run: npm run lint

# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
run: npm run lint:check
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ jobs:
- name: Build assets
run: npm run build

- name: Type-check frontend
run: npm run types

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/public/hot
/public/storage
/storage/*.key
/storage/passport
/storage/pail
/resources/js/actions
/resources/js/routes
Expand Down
17 changes: 10 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,16 @@ Vue components must have a single root element.
- Example: `$this->postJson(route('app.posts.store'))` instead of `$this->postJson('/posts')`.
- With params: `route('app.posts.ai.create.finalize', $creationId)`.

## Dusk (Browser Tests)

- In Dusk tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`.
- Example: `$browser->visit(route('login'))` instead of `$browser->visit('https://trypost.test/login')`.
- ALWAYS use `dusk` selectors (`@selector-name`) for interacting with and asserting elements. NEVER use CSS classes (`.text-red-600`), tag names, or text strings.
- Add `dusk="my-element"` attributes to Vue components and use `$browser->click('@my-element')`, `$browser->waitFor('@my-element')`, etc.
- Example: `$browser->waitFor('@input-error')` instead of `$browser->waitFor('.text-red-600')`.
## Browser Tests

- In browser tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`.
- Example: `visit(route('login'))` instead of `visit('https://trypost.test/login')`.
- ALWAYS use `data-testid` selectors (`@selector-name`) for interacting with and asserting elements. NEVER use CSS classes (`.text-red-600`), tag names, or text strings.
- The Pest browser plugin resolves `@selector` against `[data-testid]` / `[data-test]` only — `dusk="..."` attributes are NOT matched.
- Add `data-testid="my-element"` attributes to Vue components and use `$page->click('@my-element')`, `$page->assertVisible('@my-element')`, etc.
- Example: `$page->assertVisible('@input-error')` instead of `$page->assertVisible('.text-red-600')`.
- Assertions do not auto-wait — poll browser-side (see `waitForTestId`/`waitForDusk` helpers in `tests/Browser`) before asserting on async UI.
- Use `->fresh()` when acting as a factory-created user: the in-process server keeps the guard user across requests, and factory instances lack nullable columns (strict mode throws `MissingAttributeException`).

## Array Data Access

Expand Down
44 changes: 44 additions & 0 deletions app/Actions/AccessToken/ListConnectedMcpClients.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace App\Actions\AccessToken;

use App\Models\AccessToken;
use App\Models\User;
use Illuminate\Support\Collection;

class ListConnectedMcpClients
{
/**
* Account-wide MCP OAuth connections, keyed by client + owning user.
*
* @return list<array{client_id: string, name: string, user_id: string, user_name: string, can_disconnect: bool, last_used_at: mixed}>
*/
public static function forAccount(string $accountId, User $viewer): array
{
$tokens = AccessToken::query()
->whereIn('user_id', User::query()->select('id')->where('account_id', $accountId))
->activeMcpOAuth()
->with(['client', 'user'])
->get();

return $tokens
->groupBy(fn (AccessToken $token): string => "{$token->client_id}:{$token->user_id}")
->map(function (Collection $group) use ($viewer): array {
/** @var AccessToken $token */
$token = $group->first();

return [
'client_id' => $token->client_id,
'name' => $token->client->name,
'user_id' => (string) $token->user_id,
'user_name' => (string) ($token->user?->name ?? ''),
'can_disconnect' => $token->user_id === $viewer->id,
'last_used_at' => $group->max('last_used_at'),
];
})
->values()
->all();
}
}
99 changes: 99 additions & 0 deletions app/Actions/AccessToken/RevokeMcpOAuthGrants.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace App\Actions\AccessToken;

use App\Events\OnboardingStatusUpdated;
use App\Models\AccessToken;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class RevokeMcpOAuthGrants
{
/**
* Revoke MCP OAuth grants only when the user can no longer create posts in
* any workspace (demotion/removal that still leaves createPost elsewhere
* must keep the grant — MCP is account-scoped via current workspace).
*
* @return bool True when at least one grant was revoked.
*/
public static function forUserIfLacksCreatePost(User $user): bool
{
if (self::canCreatePostSomewhere($user)) {
return false;
}

return self::forUser($user);
}

/**
* Revoke every active MCP OAuth access token (and its refresh tokens) for the user.
*
* @return bool True when at least one grant was revoked.
*/
public static function forUser(User $user): bool
{
return self::revoke(
AccessToken::query()
->where('user_id', $user->id)
->mcpOAuth()
->where('revoked', false)
->get(),
$user,
);
}

/**
* Revoke active MCP OAuth grants for one OAuth client owned by the user.
*
* @return bool True when at least one grant was revoked.
*/
public static function forUserClient(User $user, string $clientId): bool
{
return self::revoke(
AccessToken::query()
->where('user_id', $user->id)
->where('client_id', $clientId)
->mcpOAuth()
->where('revoked', false)
->get(),
$user,
);
}

public static function canCreatePostSomewhere(User $user): bool
{
return $user->workspaces()
->get()
->contains(fn (Workspace $workspace): bool => $user->can('createPost', $workspace));
}

/**
* @param Collection<int, AccessToken> $tokens
*/
private static function revoke(Collection $tokens, User $user): bool
{
if ($tokens->isEmpty()) {
return false;
}

DB::transaction(function () use ($tokens): void {
$tokenIds = $tokens->pluck('id');

DB::table('oauth_refresh_tokens')
->whereIn('access_token_id', $tokenIds)
->update(['revoked' => true]);

$tokens->each(function (AccessToken $token): void {
$token->forceFill(['revoked' => true])->saveQuietly();
});
});

OnboardingStatusUpdated::dispatchForAccount($user->account, $user);

return true;
}
}
45 changes: 45 additions & 0 deletions app/Actions/AccessToken/RevokeWorkspaceApiKeys.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Actions\AccessToken;

use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Models\AccessToken;
use App\Models\Workspace;

class RevokeWorkspaceApiKeys
{
/**
* Revoke personal-access API keys for a user on one workspace.
* Used when the member is removed or demoted below Admin (manageTeam).
*
* @return int Number of tokens revoked.
*/
public static function forUserOnWorkspace(string $userId, Workspace $workspace): int
{
return AccessToken::query()
->where('user_id', $userId)
->where('workspace_id', $workspace->id)
->where('revoked', false)
->update(['revoked' => true]);
}

/**
* Admins (and account owners acting as admin) may keep workspace API keys.
* Any other role loses them.
*
* @return int Number of tokens revoked.
*/
public static function forUserUnlessAdmin(
string $userId,
Workspace $workspace,
WorkspaceRole $role,
): int {
if ($role === WorkspaceRole::Admin) {
return 0;
}

return self::forUserOnWorkspace($userId, $workspace);
}
}
Loading
Loading