diff --git a/app/Actions/Account/AccountsRequiringCancel.php b/app/Actions/Account/AccountsRequiringCancel.php new file mode 100644 index 000000000..64789ee29 --- /dev/null +++ b/app/Actions/Account/AccountsRequiringCancel.php @@ -0,0 +1,46 @@ + + */ + public static function forDeletingUser(User $user, ?Account $account, bool $isOwner): Collection + { + if ($isOwner && $account) { + $memberIds = User::query() + ->where('account_id', $account->id) + ->where('id', '!=', $user->id) + ->pluck('id'); + + $memberOwnedAccounts = Account::query() + ->whereIn('owner_id', $memberIds) + ->where('id', '!=', $account->id) + ->get(); + + return $memberOwnedAccounts + ->concat([$account]) + ->unique('id') + ->values(); + } + + return Account::query() + ->where('owner_id', $user->id) + ->get(); + } +} diff --git a/app/Actions/Account/CancelAccountSubscription.php b/app/Actions/Account/CancelAccountSubscription.php new file mode 100644 index 000000000..4df105ba6 --- /dev/null +++ b/app/Actions/Account/CancelAccountSubscription.php @@ -0,0 +1,37 @@ +subscription(Account::SUBSCRIPTION_NAME); + + if ($subscription && ! $subscription->ended()) { + $subscription->cancelNow(); + } + + return true; + } catch (Throwable $e) { + Log::warning('Failed to cancel Stripe subscription during account delete', [ + 'account_id' => $account->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Actions/Account/CancelAccounts.php b/app/Actions/Account/CancelAccounts.php new file mode 100644 index 000000000..e59c1281a --- /dev/null +++ b/app/Actions/Account/CancelAccounts.php @@ -0,0 +1,31 @@ +|iterable $accounts + * @return bool false when any cancel failed — local teardown must not proceed + */ + public static function execute(iterable $accounts): bool + { + foreach ($accounts as $account) { + if (! CancelAccountSubscription::execute($account)) { + return false; + } + } + + return true; + } +} diff --git a/app/Actions/Account/DeleteAccount.php b/app/Actions/Account/DeleteAccount.php new file mode 100644 index 000000000..b424aabbb --- /dev/null +++ b/app/Actions/Account/DeleteAccount.php @@ -0,0 +1,67 @@ + media paths for DeleteOrphanedMediaFiles after commit + */ + public static function execute(Account $account, User $owner): array + { + $mediaPaths = []; + + $owner->update(['current_workspace_id' => null]); + + Workspace::query() + ->where('account_id', $account->id) + ->get() + ->each(function (Workspace $workspace) use ($owner, &$mediaPaths): void { + ReassignCurrentWorkspace::awayFromWorkspace( + $workspace, + exceptUserId: $owner->id, + ); + + $mediaPaths = [ + ...$mediaPaths, + ...PurgeWorkspace::execute($workspace), + ]; + }); + + $owner->workspaces()->detach(); + + Invite::query()->where('account_id', $account->id)->delete(); + + $settled = SettleStrandedMember::forceDeleteMembers( + $account, + exceptUserId: $owner->id, + ); + + $mediaPaths = [ + ...$mediaPaths, + ...$settled->mediaPaths, + ]; + + $owner->update(['account_id' => null]); + + if (Account::query()->whereKey($account->id)->exists()) { + $account->subscriptions()->delete(); + $account->delete(); + } + + return $mediaPaths; + } +} diff --git a/app/Actions/Account/DeleteEmptyOwnedAccounts.php b/app/Actions/Account/DeleteEmptyOwnedAccounts.php new file mode 100644 index 000000000..1c58a68fc --- /dev/null +++ b/app/Actions/Account/DeleteEmptyOwnedAccounts.php @@ -0,0 +1,76 @@ + IDs of accounts that were deleted + */ + public static function execute( + User $user, + ?string $onlyAccountId = null, + ?string $exceptAccountId = null, + ): array { + $ids = Account::query() + ->where('owner_id', $user->id) + ->when( + $onlyAccountId, + fn ($query) => $query->whereKey($onlyAccountId), + ) + ->when( + $exceptAccountId, + fn ($query) => $query->where('id', '!=', $exceptAccountId), + ) + ->whereDoesntHave('workspaces') + ->pluck('id') + ->all(); + + return self::executeByIds($ids, $user); + } + + /** + * Cancel Stripe then delete the given empty accounts (by id). + * Safe to call after a lock is released — including after the owner user + * row was deleted (owner_id may already be null via FK). + * + * @param list $accountIds + * @return list IDs of accounts that were deleted + */ + public static function executeByIds(array $accountIds, ?User $user = null): array + { + $deleted = []; + + foreach ($accountIds as $accountId) { + $orphan = Account::query()->find($accountId); + + if (! $orphan || $orphan->workspaces()->exists()) { + continue; + } + + if (! CancelAccountSubscription::execute($orphan)) { + continue; + } + + if ($user && $user->account_id === $orphan->id) { + $user->update(['account_id' => null]); + } + + $orphan->delete(); + $deleted[] = $orphan->id; + } + + return $deleted; + } +} diff --git a/app/Actions/Account/PurgeOwnedAccounts.php b/app/Actions/Account/PurgeOwnedAccounts.php new file mode 100644 index 000000000..851b33690 --- /dev/null +++ b/app/Actions/Account/PurgeOwnedAccounts.php @@ -0,0 +1,51 @@ + + */ + public static function execute(User $user, ?Account $except = null): array + { + $mediaPaths = []; + + Account::query() + ->where('owner_id', $user->id) + ->when( + $except, + fn ($query) => $query->where('id', '!=', $except->id), + ) + ->get() + ->each(function (Account $owned) use ($user, &$mediaPaths): void { + Workspace::query() + ->where('account_id', $owned->id) + ->get() + ->each(function (Workspace $workspace) use (&$mediaPaths): void { + $mediaPaths = [ + ...$mediaPaths, + ...PurgeWorkspace::execute($workspace), + ]; + }); + + if ($user->account_id === $owned->id) { + $user->update(['account_id' => null]); + } + + $owned->delete(); + }); + + return $mediaPaths; + } +} diff --git a/app/Actions/Auth/LogoutAndInvalidateSession.php b/app/Actions/Auth/LogoutAndInvalidateSession.php new file mode 100644 index 000000000..3aae6bc9c --- /dev/null +++ b/app/Actions/Auth/LogoutAndInvalidateSession.php @@ -0,0 +1,34 @@ +delete() can re-insert the deleted user. + * + * @param array{banner?: string|null, bannerStyle?: string|null}|null $reflash + */ + public static function execute(?Request $request = null, ?array $reflash = null): void + { + $request ??= request(); + + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + $banner = data_get($reflash, 'banner'); + + if ($banner !== null) { + session()->flash('flash.banner', $banner); + session()->flash('flash.bannerStyle', data_get($reflash, 'bannerStyle', 'info')); + } + } +} diff --git a/app/Actions/Invite/AcceptInvite.php b/app/Actions/Invite/AcceptInvite.php new file mode 100644 index 000000000..ce67bb4dc --- /dev/null +++ b/app/Actions/Invite/AcceptInvite.php @@ -0,0 +1,74 @@ +email !== $user->email) { + return Result::WrongEmail; + } + + $previousAccountId = null; + + $result = DB::transaction(function () use ($user, $invite, &$previousAccountId): Result { + $lockedInvite = Invite::query() + ->whereKey($invite->id) + ->lockForUpdate() + ->first(); + + if (! $lockedInvite) { + return Result::Gone; + } + + if ($lockedInvite->accepted_at !== null) { + return Result::AlreadyAccepted; + } + + $workspaces = ResolveInviteWorkspaces::execute($lockedInvite); + + if ($workspaces->isEmpty()) { + $lockedInvite->delete(); + + return Result::Gone; + } + + // Already on the account: still attach any missing workspace memberships. + if ($user->account_id === $lockedInvite->account_id) { + AttachInviteWorkspaces::execute($user, $lockedInvite, $workspaces); + + $lockedInvite->update(['accepted_at' => now()]); + + return Result::AlreadyMember; + } + + $previousAccountId = $user->account_id; + + $user->update(['account_id' => $lockedInvite->account_id]); + $user->refresh(); + + AttachInviteWorkspaces::execute($user, $lockedInvite, $workspaces); + + $lockedInvite->update(['accepted_at' => now()]); + + return Result::Accepted; + }); + + // Invite signup leaves an empty personal account; drop it after commit + // so Stripe cancel is not held inside the invite lock. + if ($result === Result::Accepted && $previousAccountId) { + DeleteEmptyOwnedAccounts::execute($user, onlyAccountId: $previousAccountId); + } + + return $result; + } +} diff --git a/app/Actions/Invite/AttachInviteWorkspaces.php b/app/Actions/Invite/AttachInviteWorkspaces.php new file mode 100644 index 000000000..f9db999fb --- /dev/null +++ b/app/Actions/Invite/AttachInviteWorkspaces.php @@ -0,0 +1,51 @@ + $workspaces + */ + public static function execute(User $user, Invite $invite, Collection $workspaces): void + { + foreach ($workspaces as $workspace) { + $alreadyMember = $workspace->members() + ->where('users.id', $user->id) + ->exists(); + + // Never overwrite an existing pivot role (avoids demoting admins). + if (! $alreadyMember) { + $workspace->members()->attach($user->id, [ + 'role' => $invite->role->value, + ]); + } + + // Accept often switches account_id while an old personal workspace is + // still current — always land on a workspace of the invite account. + if (! self::currentWorkspaceBelongsToAccount($user)) { + $user->update(['current_workspace_id' => $workspace->id]); + $user->refresh(); + } + } + } + + public static function currentWorkspaceBelongsToAccount(User $user): bool + { + if (! $user->current_workspace_id || ! $user->account_id) { + return false; + } + + return $user->workspaces() + ->where('workspaces.id', $user->current_workspace_id) + ->where('workspaces.account_id', $user->account_id) + ->exists(); + } +} diff --git a/app/Actions/Invite/DeclineInvite.php b/app/Actions/Invite/DeclineInvite.php new file mode 100644 index 000000000..8231e4a38 --- /dev/null +++ b/app/Actions/Invite/DeclineInvite.php @@ -0,0 +1,38 @@ +email !== $user->email) { + return Result::WrongEmail; + } + + $workspacesGone = DB::transaction(function () use ($invite): bool { + $lockedInvite = Invite::query() + ->whereKey($invite->id) + ->lockForUpdate() + ->first(); + + if (! $lockedInvite) { + return true; + } + + $workspaces = ResolveInviteWorkspaces::execute($lockedInvite); + $lockedInvite->delete(); + + return $workspaces->isEmpty(); + }); + + return $workspacesGone ? Result::Gone : Result::Declined; + } +} diff --git a/app/Actions/Invite/RemoveMember.php b/app/Actions/Invite/RemoveMember.php index 0e978ec8e..fd6d0e246 100644 --- a/app/Actions/Invite/RemoveMember.php +++ b/app/Actions/Invite/RemoveMember.php @@ -4,12 +4,55 @@ namespace App\Actions\Invite; +use App\Actions\User\ReassignCurrentWorkspace; +use App\Actions\User\SettleStrandedMember; +use App\Actions\User\StrandedSettlement; +use App\Models\Account; +use App\Models\User; use App\Models\Workspace; +use Illuminate\Support\Facades\DB; class RemoveMember { public static function execute(Workspace $workspace, string $userId): void { - $workspace->members()->detach($userId); + $settlement = StrandedSettlement::none(); + + DB::transaction(function () use ($workspace, $userId, &$settlement): void { + $account = $workspace->account; + + // Serialize with DeleteWorkspace / other RemoveMember calls on this + // account so concurrent removals cannot skip stranded cleanup. + if ($account?->id) { + Account::query()->whereKey($account->id)->lockForUpdate()->first(); + } + + $user = User::query()->find($userId); + + $workspace->members()->detach($userId); + + if (! $user) { + return; + } + + $user->refresh(); + + if ($user->current_workspace_id === $workspace->id) { + ReassignCurrentWorkspace::forUserAwayFrom($user, $workspace); + $user->refresh(); + } + + // Last membership on this shared account — delete the invitee, or + // restore a personal account that still has workspaces. + if ( + $account + && $user->account_id === $account->id + && $user->id !== $account->owner_id + ) { + $settlement = SettleStrandedMember::execute($user, $account); + } + }); + + $settlement->flush(); } } diff --git a/app/Actions/Invite/ResolveInviteWorkspaces.php b/app/Actions/Invite/ResolveInviteWorkspaces.php new file mode 100644 index 000000000..369482a0c --- /dev/null +++ b/app/Actions/Invite/ResolveInviteWorkspaces.php @@ -0,0 +1,34 @@ + + */ + public static function execute(Invite $invite): Collection + { + $ids = collect(data_get($invite, 'workspaces', [])) + ->filter() + ->values() + ->all(); + + if ($ids === []) { + return collect(); + } + + return Workspace::query() + ->whereIn('id', $ids) + ->where('account_id', $invite->account_id) + ->get(); + } +} diff --git a/app/Actions/Invite/SettleAfterInvite.php b/app/Actions/Invite/SettleAfterInvite.php new file mode 100644 index 000000000..01708982b --- /dev/null +++ b/app/Actions/Invite/SettleAfterInvite.php @@ -0,0 +1,84 @@ +refresh(); + + if (AttachInviteWorkspaces::currentWorkspaceBelongsToAccount($user)) { + return redirect()->route('app.calendar'); + } + + $hasSameAccountWorkspace = $user->workspaces() + ->where('workspaces.account_id', $user->account_id) + ->exists(); + + if (! $hasSameAccountWorkspace && ! $user->isAccountOwner() && $user->account) { + $userId = $user->id; + $leavingAccount = $user->account; + $banner = session('flash.banner'); + $bannerStyle = session('flash.bannerStyle'); + + // Logout first while the row still exists. SessionGuard cycles the + // remember token via save() — if we delete first on the Auth user + // instance, logout re-inserts them. + if (Auth::id() === $userId) { + LogoutAndInvalidateSession::execute(request(), [ + 'banner' => $banner, + 'bannerStyle' => $bannerStyle, + ]); + } + + $stranded = User::query()->find($userId); + + if ($stranded) { + SettleStrandedMember::execute($stranded, $leavingAccount)->flush(); + } + + $user = User::query()->find($userId); + + if (! $user) { + return redirect()->route('login'); + } + } + + $fallback = $user->workspaces() + ->where('workspaces.account_id', $user->account_id) + ->first(); + + if ($fallback) { + $user->update(['current_workspace_id' => $fallback->id]); + + return redirect()->route('app.calendar'); + } + + return redirect()->route('app.workspaces.create'); + } + + public static function flashAndRedirect(User $user, string $banner, string $style): RedirectResponse + { + session()->flash('flash.banner', $banner); + session()->flash('flash.bannerStyle', $style); + + return self::execute($user->fresh() ?? $user); + } +} diff --git a/app/Actions/Media/DeleteOrphanedMediaFiles.php b/app/Actions/Media/DeleteOrphanedMediaFiles.php new file mode 100644 index 000000000..4d2cd592c --- /dev/null +++ b/app/Actions/Media/DeleteOrphanedMediaFiles.php @@ -0,0 +1,39 @@ + $mediaOrPaths + */ + public static function execute(iterable $mediaOrPaths): void + { + collect($mediaOrPaths) + ->map(function (Media|string|null $item): ?string { + if ($item instanceof Media) { + return $item->path; + } + + return $item; + }) + ->filter() + ->unique() + ->each(function (string $path): void { + $stillReferenced = Media::query() + ->where('path', $path) + ->exists(); + + if (! $stillReferenced) { + Storage::delete($path); + } + }); + } +} diff --git a/app/Actions/Media/DeleteWorkspaceMedia.php b/app/Actions/Media/DeleteWorkspaceMedia.php new file mode 100644 index 000000000..e0c5a2219 --- /dev/null +++ b/app/Actions/Media/DeleteWorkspaceMedia.php @@ -0,0 +1,34 @@ + + */ + public static function purgeRecords(Workspace $workspace): array + { + $query = Media::query() + ->where('mediable_type', Relation::getMorphAlias(Workspace::class)) + ->where('mediable_id', $workspace->id); + + /** @var list $paths */ + $paths = $query->pluck('path')->all(); + $query->delete(); + + return $paths; + } +} diff --git a/app/Actions/User/DeleteUser.php b/app/Actions/User/DeleteUser.php new file mode 100644 index 000000000..c7d720c5d --- /dev/null +++ b/app/Actions/User/DeleteUser.php @@ -0,0 +1,66 @@ +account; + $isOwner = $user->isAccountOwner(); + + if (! CancelAccounts::execute( + AccountsRequiringCancel::forDeletingUser($user, $account, $isOwner), + )) { + return false; + } + + $mediaPaths = DB::transaction(function () use ($user, $account, $isOwner): array { + $user->update(['current_workspace_id' => null]); + + $mediaPaths = []; + + if ($isOwner && $account) { + $mediaPaths = DeleteAccount::execute($account, $user); + } else { + // Members must never delete shared-account workspaces (even ones + // they created). Only tear down accounts/workspaces they own. + $user->workspaces()->detach(); + $mediaPaths = PurgeOwnedAccounts::execute($user); + $user->update(['account_id' => null]); + } + + return [ + ...$mediaPaths, + ...PurgeUserAccess::execute($user), + ]; + }); + + DeleteOrphanedMediaFiles::execute($mediaPaths); + + // Logout while the user row still exists — SessionGuard cycles the + // remember token via save(), which fails if the user was already deleted. + LogoutAndInvalidateSession::execute($request); + $user->delete(); + + return true; + } +} diff --git a/app/Actions/User/PurgeUserAccess.php b/app/Actions/User/PurgeUserAccess.php new file mode 100644 index 000000000..a095ceeb9 --- /dev/null +++ b/app/Actions/User/PurgeUserAccess.php @@ -0,0 +1,36 @@ + media paths for DeleteOrphanedMediaFiles after commit + */ + public static function execute(User $user): array + { + $userMediaQuery = Media::query() + ->where('mediable_type', Relation::getMorphAlias(User::class)) + ->where('mediable_id', $user->id); + + /** @var list $mediaPaths */ + $mediaPaths = $userMediaQuery->pluck('path')->all(); + $userMediaQuery->delete(); + + $user->tokens()->each(function (Token $token): void { + $token->revoke(); + $token->refreshToken?->revoke(); + }); + + return $mediaPaths; + } +} diff --git a/app/Actions/User/ReassignCurrentWorkspace.php b/app/Actions/User/ReassignCurrentWorkspace.php new file mode 100644 index 000000000..aa9a3e136 --- /dev/null +++ b/app/Actions/User/ReassignCurrentWorkspace.php @@ -0,0 +1,97 @@ +current_workspace_id !== $workspace->id) { + return; + } + + $fallback = $user->workspaces() + ->where('workspaces.id', '!=', $workspace->id) + ->where('workspaces.account_id', $workspace->account_id) + ->first(); + + if (! $fallback && $attachOwnerFallback) { + $fallback = self::ownerAccountFallback($user, $workspace, $account); + } + + $user->update(['current_workspace_id' => $fallback?->id]); + } + + /** + * Point every user whose current workspace is $workspace at another + * same-account membership (or null). Used when the workspace is deleted. + */ + public static function awayFromWorkspace( + Workspace $workspace, + ?string $exceptUserId = null, + bool $attachOwnerFallback = false, + ?Account $account = null, + ): void { + User::query() + ->where('current_workspace_id', $workspace->id) + ->when( + $exceptUserId, + fn ($query) => $query->where('id', '!=', $exceptUserId), + ) + ->get() + ->each(fn (User $user) => self::forUserAwayFrom( + $user, + $workspace, + $attachOwnerFallback, + $account, + )); + } + + private static function ownerAccountFallback( + User $user, + Workspace $deleting, + ?Account $account, + ): ?Workspace { + // Use the already-loaded account owner_id — never isAccountOwner(), + // which can touch the account relation under shouldBeStrict(). + $isOwnerOfDeletingAccount = $account !== null + && $user->id === $account->owner_id + && $user->account_id === $deleting->account_id; + + if (! $isOwnerOfDeletingAccount) { + return null; + } + + $fallback = Workspace::query() + ->where('account_id', $deleting->account_id) + ->where('id', '!=', $deleting->id) + ->first(); + + if ($fallback && ! $user->belongsToWorkspace($fallback)) { + $fallback->members()->syncWithoutDetaching([ + $user->id => ['role' => Role::Admin->value], + ]); + } + + return $fallback; + } +} diff --git a/app/Actions/User/SettleStrandedMember.php b/app/Actions/User/SettleStrandedMember.php new file mode 100644 index 000000000..4053262bc --- /dev/null +++ b/app/Actions/User/SettleStrandedMember.php @@ -0,0 +1,163 @@ +id === $leavingAccount->owner_id) { + return StrandedSettlement::none(); + } + + if ($user->workspaces() + ->where('workspaces.account_id', $leavingAccount->id) + ->exists() + ) { + return StrandedSettlement::none(); + } + + if (! $forceDelete) { + $personalAccount = Account::query() + ->where('owner_id', $user->id) + ->where('id', '!=', $leavingAccount->id) + ->whereHas('workspaces') + ->first(); + + if ($personalAccount) { + $fallback = $user->workspaces() + ->where('workspaces.account_id', $personalAccount->id) + ->first(); + + $user->update([ + 'account_id' => $personalAccount->id, + 'current_workspace_id' => $fallback?->id, + ]); + + return StrandedSettlement::none(); + } + } + + $mediaPaths = $forceDelete + ? PurgeOwnedAccounts::execute($user, $leavingAccount) + : []; + + $emptyAccountIds = []; + + if (! $forceDelete) { + $emptyAccountIds = Account::query() + ->where('owner_id', $user->id) + ->where('id', '!=', $leavingAccount->id) + ->whereDoesntHave('workspaces') + ->pluck('id') + ->all(); + } + + $mediaPaths = [ + ...$mediaPaths, + ...PurgeUserAccess::execute($user), + ]; + + $user->workspaces()->detach(); + $user->update([ + 'account_id' => null, + 'current_workspace_id' => null, + ]); + $user->delete(); + + return new StrandedSettlement( + mediaPaths: $mediaPaths, + emptyAccountIds: $emptyAccountIds, + ); + } + + private static function forAccountMembers( + Account $account, + ?string $exceptUserId, + bool $onlyWithoutAccountWorkspaces, + bool $forceDelete, + ): StrandedSettlement { + $settlement = StrandedSettlement::none(); + + User::query() + ->where('account_id', $account->id) + ->when( + $exceptUserId, + fn ($query) => $query->where('id', '!=', $exceptUserId), + ) + ->when( + $onlyWithoutAccountWorkspaces, + fn ($query) => $query->whereDoesntHave( + 'workspaces', + fn ($workspaces) => $workspaces->where('workspaces.account_id', $account->id), + ), + ) + ->get() + ->each(function (User $member) use ($account, $forceDelete, &$settlement): void { + $settled = $forceDelete + ? self::forceDelete($member, $account) + : self::execute($member, $account); + + $settlement = $settlement->merge($settled); + }); + + return $settlement; + } +} diff --git a/app/Actions/User/StrandedSettlement.php b/app/Actions/User/StrandedSettlement.php new file mode 100644 index 000000000..2d1daff08 --- /dev/null +++ b/app/Actions/User/StrandedSettlement.php @@ -0,0 +1,49 @@ + $mediaPaths + * @param list $emptyAccountIds + */ + public function __construct( + public array $mediaPaths = [], + public array $emptyAccountIds = [], + ) {} + + public static function none(): self + { + return new self; + } + + public function merge(self $other): self + { + return new self( + mediaPaths: [...$this->mediaPaths, ...$other->mediaPaths], + emptyAccountIds: array_values(array_unique([ + ...$this->emptyAccountIds, + ...$other->emptyAccountIds, + ])), + ); + } + + /** + * Cancel/delete empty personal leftovers, then remove orphaned media files. + */ + public function flush(): void + { + DeleteEmptyOwnedAccounts::executeByIds($this->emptyAccountIds); + DeleteOrphanedMediaFiles::execute($this->mediaPaths); + } +} diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php index 57a545b3b..b7d97de97 100644 --- a/app/Actions/Workspace/DeleteWorkspace.php +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -4,34 +4,107 @@ namespace App\Actions\Workspace; +use App\Actions\User\ReassignCurrentWorkspace; +use App\Actions\User\SettleStrandedMember; +use App\Actions\User\StrandedSettlement; use App\Jobs\PostHog\SyncAccountUsage; -use App\Models\User; +use App\Models\Account; +use App\Models\Invite; use App\Models\Workspace; use App\Services\PostHogService; +use Illuminate\Support\Facades\DB; class DeleteWorkspace { - public static function execute(User $user, Workspace $workspace): void + /** + * Delete a workspace and settle stranded members (restore or delete). + * + * Returns false when SaaS mode blocks deleting the account's last workspace. + * The account row is locked so concurrent deletes cannot race past that guard. + */ + public static function execute(Workspace $workspace): bool { - User::where('current_workspace_id', $workspace->id) - ->get() - ->each(function (User $affected) use ($workspace): void { - $fallback = $affected->workspaces() - ->where('workspaces.id', '!=', $workspace->id) - ->first(); - - $affected->update(['current_workspace_id' => $fallback?->id]); - }); - $account = $workspace->account; $accountId = (string) $workspace->account_id; + $deleted = false; + $settlement = StrandedSettlement::none(); + + DB::transaction(function () use ($workspace, $account, &$deleted, &$settlement): void { + // Serialize deletes per account so the last-workspace SaaS guard + // cannot race with a concurrent delete of the sibling workspace. + if ($account?->id) { + Account::query()->whereKey($account->id)->lockForUpdate()->first(); + } + + $workspaceCount = Workspace::query() + ->where('account_id', $workspace->account_id) + ->count(); + + if (! config('trypost.self_hosted') && $workspaceCount <= 1) { + return; + } + + ReassignCurrentWorkspace::awayFromWorkspace( + workspace: $workspace, + attachOwnerFallback: true, + account: $account, + ); + + self::pruneInvitesForWorkspace($workspace); + + // Capture paths inside the lock so uploads that raced into the + // transaction are included in post-commit filesystem cleanup. + $mediaPaths = PurgeWorkspace::execute($workspace); + + $settlement = new StrandedSettlement(mediaPaths: $mediaPaths); + + if ($account) { + $settlement = $settlement->merge( + SettleStrandedMember::strandedWithoutMemberships( + $account, + exceptUserId: $account->owner_id, + ), + ); + } - $workspace->delete(); + $deleted = true; + }); + + if (! $deleted) { + return false; + } + + $settlement->flush(); $account?->syncWorkspaceQuantity(); if (PostHogService::isEnabled()) { SyncAccountUsage::dispatch($accountId, null); } + + return true; + } + + private static function pruneInvitesForWorkspace(Workspace $workspace): void + { + Invite::query() + ->where('account_id', $workspace->account_id) + ->whereNull('accepted_at') + ->whereJsonContains('workspaces', $workspace->id) + ->get() + ->each(function (Invite $invite) use ($workspace): void { + $remaining = collect(data_get($invite, 'workspaces', [])) + ->reject(fn (mixed $id): bool => (string) $id === (string) $workspace->id) + ->values() + ->all(); + + if ($remaining === []) { + $invite->delete(); + + return; + } + + $invite->update(['workspaces' => $remaining]); + }); } } diff --git a/app/Actions/Workspace/PurgeWorkspace.php b/app/Actions/Workspace/PurgeWorkspace.php new file mode 100644 index 000000000..74eabf393 --- /dev/null +++ b/app/Actions/Workspace/PurgeWorkspace.php @@ -0,0 +1,27 @@ + + */ + public static function execute(Workspace $workspace): array + { + $mediaPaths = DeleteWorkspaceMedia::purgeRecords($workspace); + $workspace->delete(); + + return $mediaPaths; + } +} diff --git a/app/Enums/Invite/Result.php b/app/Enums/Invite/Result.php new file mode 100644 index 000000000..689678c03 --- /dev/null +++ b/app/Enums/Invite/Result.php @@ -0,0 +1,38 @@ + __('settings.members.flash.wrong_email'), + self::Gone => __('settings.members.flash.invite_workspace_gone'), + self::AlreadyAccepted, self::AlreadyMember => __('settings.members.flash.already_member'), + self::Accepted => __('settings.members.flash.invite_accepted'), + self::Declined => __('settings.members.flash.invite_declined'), + }; + } + + /** + * @return 'danger'|'info'|'success' + */ + public function flashStyle(): string + { + return match ($this) { + self::WrongEmail, self::Gone => 'danger', + self::AlreadyAccepted, self::AlreadyMember, self::Declined => 'info', + self::Accepted => 'success', + }; + } +} diff --git a/app/Http/Controllers/App/Settings/ProfileController.php b/app/Http/Controllers/App/Settings/ProfileController.php index 24d05741e..906b97942 100644 --- a/app/Http/Controllers/App/Settings/ProfileController.php +++ b/app/Http/Controllers/App/Settings/ProfileController.php @@ -4,16 +4,13 @@ namespace App\Http\Controllers\App\Settings; +use App\Actions\User\DeleteUser; use App\Http\Controllers\Controller; use App\Http\Requests\App\Settings\ProfileDeleteRequest; use App\Http\Requests\App\Settings\ProfileUpdateRequest; -use App\Models\Account; -use App\Models\Workspace; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; @@ -85,50 +82,12 @@ public function updateLanguage(Request $request): RedirectResponse public function destroy(ProfileDeleteRequest $request): RedirectResponse { - $user = $request->user(); + if (! DeleteUser::execute($request->user(), $request)) { + session()->flash('flash.banner', __('settings.flash.delete_failed_billing')); + session()->flash('flash.bannerStyle', 'danger'); - DB::transaction(function () use ($user) { - $user->update(['current_workspace_id' => null]); - - $account = $user->account; - $isOwner = $user->isAccountOwner(); - - $ownedWorkspaces = Workspace::where('user_id', $user->id)->get(); - - foreach ($ownedWorkspaces as $workspace) { - foreach ($workspace->members as $member) { - if ($member->id !== $user->id && $member->current_workspace_id === $workspace->id) { - $otherWorkspace = $member->workspaces() - ->where('workspaces.id', '!=', $workspace->id) - ->first(); - $member->update(['current_workspace_id' => $otherWorkspace?->id]); - } - } - - $workspace->posts()->delete(); - $workspace->socialAccounts()->delete(); - $workspace->signatures()->delete(); - $workspace->labels()->delete(); - $workspace->members()->detach(); - $workspace->delete(); - } - - $user->workspaces()->detach(); - - if ($account && $isOwner) { - if ($account->subscribed(Account::SUBSCRIPTION_NAME)) { - $account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow(); - } - - $account->subscriptions()->delete(); - $account->delete(); - } - }); - - Auth::logout(); - $user->delete(); - $request->session()->invalidate(); - $request->session()->regenerateToken(); + return to_route('app.profile.edit'); + } return redirect('/'); } diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index 5b2326f2d..4b47647a6 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -53,7 +53,7 @@ public function index(Request $request): Response { $user = $request->user(); - $workspaces = $user->workspaces() + $workspaces = $user->accountWorkspaces() ->with('media') ->withCount(['socialAccounts', 'posts']) ->latest() @@ -67,6 +67,8 @@ public function index(Request $request): Response public function create(Request $request): Response|RedirectResponse { + $this->authorize('create', Workspace::class); + if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($request->user())) { return $redirect; } @@ -132,6 +134,8 @@ public function switch(Request $request, Workspace $workspace): RedirectResponse { $user = $request->user(); + $this->authorize('view', $workspace); + if (! $user->belongsToWorkspace($workspace)) { abort(403); } @@ -154,6 +158,17 @@ public function settings(Request $request): Response|RedirectResponse return Inertia::render('settings/workspace/Workspace', [ 'workspace' => $workspace, + 'isOnlyWorkspace' => ! config('trypost.self_hosted') + && $workspace->account->workspaces()->count() <= 1, + 'otherMemberCount' => $workspace->members() + ->where('users.id', '!=', $user->id) + ->whereDoesntHave( + 'workspaces', + fn ($query) => $query + ->where('workspaces.account_id', $workspace->account_id) + ->where('workspaces.id', '!=', $workspace->id), + ) + ->count(), ]); } @@ -235,15 +250,21 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons { $this->authorize('delete', $workspace); - $user = $request->user(); - - if (! config('trypost.self_hosted') && $workspace->account->workspaces()->count() <= 1) { + if (! DeleteWorkspace::execute($workspace)) { return back()->with('flash.error', __('workspaces.cannot_delete_last')); } - DeleteWorkspace::execute($user, $workspace); + $request->user()->refresh(); + + // No current left (self-hosted last delete / no fallback) — go to create + // so EnsureHasWorkspace cannot bounce and drop the flash. + if (! $request->user()->current_workspace_id) { + return redirect()->route('app.workspaces.create') + ->with('flash.success', __('workspaces.flash.deleted')); + } - return redirect()->route('app.workspaces.index') + // Fallback workspace already set — back into the app, not the picker. + return redirect()->route('app.calendar') ->with('flash.success', __('workspaces.flash.deleted')); } } diff --git a/app/Http/Controllers/Auth/AcceptInviteController.php b/app/Http/Controllers/Auth/AcceptInviteController.php index ea0db7f76..fadcb8b64 100644 --- a/app/Http/Controllers/Auth/AcceptInviteController.php +++ b/app/Http/Controllers/Auth/AcceptInviteController.php @@ -4,9 +4,12 @@ namespace App\Http\Controllers\Auth; +use App\Actions\Invite\AcceptInvite; +use App\Actions\Invite\DeclineInvite; +use App\Actions\Invite\ResolveInviteWorkspaces; +use App\Actions\Invite\SettleAfterInvite; use App\Http\Controllers\Controller; use App\Models\Invite; -use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -21,12 +24,23 @@ public function show(Invite $invite): Response { $invite->load('account'); - $firstWorkspaceId = collect($invite->workspaces ?? [])->first(); - $workspace = $firstWorkspaceId ? Workspace::find($firstWorkspaceId) : null; + $workspaces = ResolveInviteWorkspaces::execute($invite); + $expired = $workspaces->isEmpty(); + if ($expired) { + // Non-mutating for crawler/email prefetch: cleanup happens on + // workspace delete and on accept/decline of a dead invite. + return Inertia::render('auth/AcceptInvite', [ + 'expired' => true, + 'invite' => null, + ]); + } + + $workspace = $workspaces->first(); $role = $invite->role; return Inertia::render('auth/AcceptInvite', [ + 'expired' => false, 'invite' => [ 'id' => $invite->id, 'email' => $invite->email, @@ -34,10 +48,10 @@ public function show(Invite $invite): Response 'id' => $invite->account->id, 'name' => $invite->account->name, ], - 'workspace' => $workspace ? [ + 'workspace' => [ 'id' => $workspace->id, 'name' => $workspace->name, - ] : null, + ], 'role' => [ 'value' => $role->value, 'label' => $role->label(), @@ -51,53 +65,13 @@ public function show(Invite $invite): Response */ public function accept(Request $request, Invite $invite): RedirectResponse { - $user = $request->user(); - - // Verify the invite is for this user - if ($invite->email !== $user->email) { - session()->flash('flash.banner', __('settings.members.flash.wrong_email')); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('app.calendar'); - } - - // Check if already a member of the account - if ($user->account_id === $invite->account_id) { - $invite->update(['accepted_at' => now()]); - - session()->flash('flash.banner', __('settings.members.flash.already_member')); - session()->flash('flash.bannerStyle', 'info'); - - return redirect()->route('app.calendar'); - } - - // Add user to the account - $user->update(['account_id' => $invite->account_id]); + $result = AcceptInvite::execute($request->user(), $invite); - // Attach user to the invited workspaces - if ($invite->workspaces) { - foreach ($invite->workspaces as $workspaceId) { - $workspace = Workspace::find($workspaceId); - - if ($workspace && $workspace->account_id === $invite->account_id) { - $workspace->members()->syncWithoutDetaching([ - $user->id => ['role' => $invite->role->value], - ]); - - // Set first workspace as current - if (! $user->current_workspace_id) { - $user->update(['current_workspace_id' => $workspace->id]); - } - } - } - } - - $invite->update(['accepted_at' => now()]); - - session()->flash('flash.banner', __('settings.members.flash.invite_accepted')); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('app.calendar'); + return SettleAfterInvite::flashAndRedirect( + $request->user(), + $result->flashBanner(), + $result->flashStyle(), + ); } /** @@ -105,21 +79,12 @@ public function accept(Request $request, Invite $invite): RedirectResponse */ public function decline(Request $request, Invite $invite): RedirectResponse { - $user = $request->user(); - - // Verify the invite is for this user - if ($invite->email !== $user->email) { - session()->flash('flash.banner', __('settings.members.flash.wrong_email')); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('app.calendar'); - } - - $invite->delete(); - - session()->flash('flash.banner', __('settings.members.flash.invite_declined')); - session()->flash('flash.bannerStyle', 'info'); + $result = DeclineInvite::execute($request->user(), $invite); - return redirect()->route('app.calendar'); + return SettleAfterInvite::flashAndRedirect( + $request->user(), + $result->flashBanner(), + $result->flashStyle(), + ); } } diff --git a/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php b/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php index 4c5715965..e8cda84b5 100644 --- a/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php +++ b/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php @@ -8,6 +8,7 @@ use App\Enums\Workspace\BrandVoiceTrait; use App\Enums\Workspace\ContentLanguage; use App\Enums\Workspace\ImageStyle; +use App\Models\Workspace; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -15,7 +16,7 @@ class StoreWorkspaceRequest extends FormRequest { public function authorize(): bool { - return true; + return $this->user()->can('create', Workspace::class); } public function rules(): array diff --git a/app/Models/Traits/HasWorkspace.php b/app/Models/Traits/HasWorkspace.php index f041cc53d..7fc998859 100644 --- a/app/Models/Traits/HasWorkspace.php +++ b/app/Models/Traits/HasWorkspace.php @@ -37,13 +37,28 @@ public function switchWorkspace(Workspace $workspace): void } /** - * Check if user belongs to a workspace (owner or member). + * Check if user belongs to a workspace on their current account. */ public function belongsToWorkspace(Workspace $workspace): bool { + if ($workspace->account_id !== $this->account_id) { + return false; + } + return $this->workspaces()->where('workspaces.id', $workspace->id)->exists(); } + /** + * Workspaces the user can use on their current account (never cross-account). + * + * @return BelongsToMany + */ + public function accountWorkspaces(): BelongsToMany + { + return $this->workspaces() + ->where('workspaces.account_id', $this->account_id); + } + /** * Get the count of workspaces the user owns. */ diff --git a/app/Models/User.php b/app/Models/User.php index dc06e3e9a..0395dd668 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -103,7 +103,18 @@ public function account(): BelongsTo public function isAccountOwner(): bool { - return $this->id === $this->account?->owner_id; + if (! $this->account_id) { + return false; + } + + if ($this->relationLoaded('account')) { + return $this->id === $this->account?->owner_id; + } + + return Account::query() + ->whereKey($this->account_id) + ->where('owner_id', $this->id) + ->exists(); } public function wantsEmailFor(NotificationType $type): bool diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index ad4f40b34..1bf5d51d7 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -32,6 +32,7 @@ public function update(User $user, Workspace $workspace): bool public function delete(User $user, Workspace $workspace): bool { + // Owner-only: deleting a workspace changes Stripe subscription quantity. return $this->isOwner($user, $workspace); } diff --git a/lang/ar/auth.php b/lang/ar/auth.php index d2a32de5f..c40107c94 100644 --- a/lang/ar/auth.php +++ b/lang/ar/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'سجّل الدخول أو أنشئ حسابًا لقبول هذه الدعوة.', 'log_in' => 'تسجيل الدخول', 'create_account' => 'إنشاء حساب', + 'expired_title' => 'هذه الدعوة لم تعد صالحة', + 'expired_description' => 'تم حذف مساحة العمل المرتبطة بهذه الدعوة. اطلب دعوة جديدة من مالك الحساب إذا كنت لا تزال بحاجة إلى الوصول.', + 'expired_action' => 'الانتقال إلى الصفحة الرئيسية', ], ]; diff --git a/lang/ar/settings.php b/lang/ar/settings.php index fc98446e5..be4edd9a7 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -110,11 +110,11 @@ 'heading' => 'حذف الحساب', 'description' => 'احذف حسابك وجميع موارده', 'warning' => 'تحذير', - 'warning_message' => 'يرجى المتابعة بحذر، لا يمكن التراجع عن هذا الإجراء.', + 'warning_message' => 'تابع بحذر: لا يمكن التراجع عن هذا. إذا كنت مالك هذا الحساب، فسيُحذف الأعضاء المدعوون أيضًا نهائيًا.', 'button' => 'حذف الحساب', 'modal_title' => 'هل أنت متأكد من رغبتك في حذف حسابك؟', - 'modal_description_password' => 'بمجرد حذف حسابك، ستُحذف جميع موارده وبياناته نهائيًا أيضًا. يرجى إدخال كلمة المرور للتأكيد.', - 'modal_description_email' => 'بمجرد حذف حسابك، ستُحذف جميع موارده وبياناته نهائيًا أيضًا. يرجى كتابة بريدك الإلكتروني :email للتأكيد.', + 'modal_description_password' => 'بمجرد حذف حسابك، ستُحذف جميع موارده وبياناته نهائيًا أيضًا. إذا كنت مالك الحساب، فسيُحذف الأعضاء المدعوون أيضًا نهائيًا. يرجى إدخال كلمة المرور للتأكيد.', + 'modal_description_email' => 'بمجرد حذف حسابك، ستُحذف جميع موارده وبياناته نهائيًا أيضًا. إذا كنت مالك الحساب، فسيُحذف الأعضاء المدعوون أيضًا نهائيًا. يرجى كتابة بريدك الإلكتروني :email للتأكيد.', 'password' => 'كلمة المرور', 'password_placeholder' => 'كلمة المرور', 'email_placeholder' => 'بريد حسابك الإلكتروني', @@ -140,6 +140,20 @@ 'name' => 'الاسم', 'name_placeholder' => 'مساحة عملي', 'save' => 'حفظ', + 'danger_description' => 'إجراءات لا يمكن التراجع عنها.', + 'delete_warning' => 'تحذير', + 'delete_title' => 'حذف مساحة العمل هذه', + 'delete_description' => 'يحذف مساحة العمل هذه وكل ما فيها نهائيًا — المنشورات والحسابات المتصلة والوسائط. ستُحاسب على مساحة عمل واحدة أقل.', + 'delete_description_self_hosted' => 'يحذف مساحة العمل هذه ومنشوراتها والحسابات المتصلة والوسائط نهائيًا.', + 'delete_only_description' => 'لا يمكنك حذف مساحة العمل الوحيدة لديك. ألغِ الاشتراك في الفوترة لإيقاف الدفع، أو احذف حسابك في المصادقة لإلغاء الفوترة وإزالة كل شيء نهائيًا.', + 'delete_go_to_billing' => 'الانتقال إلى الفوترة', + 'delete_go_to_delete_account' => 'حذف الحساب', + 'delete_members_warning' => '{1}سيفقد عضو آخر واحد الوصول. الأعضاء الذين ليس لديهم مساحة عمل أخرى في TryPost سيُحذف حسابهم نهائيًا.|[2,*]سيفقد :count أعضاء آخرين الوصول. الأعضاء الذين ليس لديهم مساحة عمل أخرى في TryPost سيُحذف حسابهم نهائيًا.', + 'delete_action' => 'حذف مساحة العمل', + 'delete_cancel' => 'إلغاء', + 'delete_confirm_title' => 'حذف مساحة العمل؟', + 'delete_confirm_description' => 'سيؤدي هذا إلى حذف مساحة العمل وجميع بياناتها نهائيًا. سيتم تحديث الفوترة لتعكس مساحة عمل أقل.', + 'delete_confirm_description_self_hosted' => 'سيؤدي هذا إلى حذف مساحة العمل وجميع بياناتها نهائيًا.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'هذه الدعوة مخصصة لبريد إلكتروني مختلف.', 'already_member' => 'أنت عضو بالفعل في مساحة العمل هذه.', 'invite_accepted' => 'مرحبًا! أنت الآن عضو في مساحة العمل.', + 'invite_workspace_gone' => 'هذه الدعوة لم تعد صالحة لأن مساحة العمل حُذفت.', 'invite_declined' => 'تم رفض الدعوة.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'تم تحديث الإعدادات بنجاح!', 'photo_updated' => 'تم تحديث الصورة بنجاح!', 'photo_deleted' => 'تمت إزالة الصورة بنجاح!', + 'delete_failed_billing' => 'تعذّر إلغاء اشتراكك لدى مزود الفوترة. لم يُحذف أي شيء. يُرجى المحاولة مرة أخرى أو التواصل مع الدعم.', 'logo_updated' => 'تم رفع الشعار بنجاح!', 'logo_deleted' => 'تمت إزالة الشعار بنجاح!', 'notifications_updated' => 'تم تحديث تفضيلات الإشعارات!', diff --git a/lang/de/auth.php b/lang/de/auth.php index 481d5b340..f9536dbc1 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -138,6 +138,9 @@ 'login_prompt' => 'Melde dich an oder erstelle ein Konto, um diese Einladung anzunehmen.', 'log_in' => 'Anmelden', 'create_account' => 'Konto erstellen', + 'expired_title' => 'Diese Einladung ist nicht mehr gültig', + 'expired_description' => 'Der Workspace für diese Einladung wurde gelöscht. Bitte den Kontoinhaber um eine neue Einladung, falls du weiterhin Zugriff brauchst.', + 'expired_action' => 'Zur Startseite', ], ]; diff --git a/lang/de/settings.php b/lang/de/settings.php index 2d19b929c..8bb206ab3 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -112,11 +112,11 @@ 'heading' => 'Konto löschen', 'description' => 'Lösche dein Konto und alle zugehörigen Ressourcen', 'warning' => 'Warnung', - 'warning_message' => 'Bitte gehe mit Vorsicht vor, dies kann nicht rückgängig gemacht werden.', + 'warning_message' => 'Sei vorsichtig: Das kann nicht rückgängig gemacht werden. Wenn du Eigentümer dieses Kontos bist, werden eingeladene Mitglieder ebenfalls dauerhaft gelöscht.', 'button' => 'Konto löschen', 'modal_title' => 'Möchtest du dein Konto wirklich löschen?', - 'modal_description_password' => 'Sobald dein Konto gelöscht ist, werden auch alle zugehörigen Ressourcen und Daten dauerhaft gelöscht. Bitte gib dein Passwort ein, um zu bestätigen.', - 'modal_description_email' => 'Sobald dein Konto gelöscht ist, werden auch alle zugehörigen Ressourcen und Daten dauerhaft gelöscht. Bitte gib deine E-Mail-Adresse :email ein, um zu bestätigen.', + 'modal_description_password' => 'Sobald dein Konto gelöscht ist, werden auch alle zugehörigen Ressourcen und Daten dauerhaft gelöscht. Wenn du Eigentümer des Kontos bist, werden eingeladene Mitglieder ebenfalls dauerhaft gelöscht. Bitte gib dein Passwort ein, um zu bestätigen.', + 'modal_description_email' => 'Sobald dein Konto gelöscht ist, werden auch alle zugehörigen Ressourcen und Daten dauerhaft gelöscht. Wenn du Eigentümer des Kontos bist, werden eingeladene Mitglieder ebenfalls dauerhaft gelöscht. Bitte gib deine E-Mail-Adresse :email ein, um zu bestätigen.', 'password' => 'Passwort', 'password_placeholder' => 'Passwort', 'email_placeholder' => 'E-Mail deines Kontos', @@ -142,6 +142,20 @@ 'name' => 'Name', 'name_placeholder' => 'Mein Workspace', 'save' => 'Speichern', + 'danger_description' => 'Unwiderrufliche Aktionen.', + 'delete_warning' => 'Warnung', + 'delete_title' => 'Diesen Workspace löschen', + 'delete_description' => 'Löscht diesen Workspace dauerhaft — inklusive Beiträge, verbundener Konten und Medien. Du wirst für einen Workspace weniger berechnet.', + 'delete_description_self_hosted' => 'Löscht diesen Workspace dauerhaft inklusive Beiträge, verbundener Konten und Medien.', + 'delete_only_description' => 'Du kannst deinen einzigen Workspace nicht löschen. Kündige das Abonnement in der Abrechnung, um die Zahlung zu stoppen, oder lösche dein Konto unter Authentifizierung, um die Abrechnung zu beenden und alles dauerhaft zu entfernen.', + 'delete_go_to_billing' => 'Zur Abrechnung', + 'delete_go_to_delete_account' => 'Konto löschen', + 'delete_members_warning' => '{1}:count weiteres Mitglied verliert den Zugriff. Mitglieder ohne anderen TryPost-Workspace werden dauerhaft gelöscht.|[2,*]:count weitere Mitglieder verlieren den Zugriff. Mitglieder ohne anderen TryPost-Workspace werden dauerhaft gelöscht.', + 'delete_action' => 'Workspace löschen', + 'delete_cancel' => 'Abbrechen', + 'delete_confirm_title' => 'Workspace löschen?', + 'delete_confirm_description' => 'Dadurch wird der Workspace und alle seine Daten dauerhaft gelöscht. Deine Abrechnung wird um einen Workspace reduziert.', + 'delete_confirm_description_self_hosted' => 'Dadurch wird der Workspace und alle seine Daten dauerhaft gelöscht.', ], 'brand' => [ @@ -285,6 +299,7 @@ 'wrong_email' => 'Diese Einladung gilt für eine andere E-Mail-Adresse.', 'already_member' => 'Du bist bereits Mitglied dieses Workspace.', 'invite_accepted' => 'Willkommen! Du bist jetzt Mitglied des Workspace.', + 'invite_workspace_gone' => 'Diese Einladung ist nicht mehr gültig, weil der Workspace gelöscht wurde.', 'invite_declined' => 'Einladung abgelehnt.', ], ], @@ -312,6 +327,7 @@ 'workspace_updated' => 'Einstellungen erfolgreich aktualisiert!', 'photo_updated' => 'Foto erfolgreich aktualisiert!', 'photo_deleted' => 'Foto erfolgreich entfernt!', + 'delete_failed_billing' => 'Wir konnten dein Abonnement beim Zahlungsanbieter nicht kündigen. Es wurde nichts gelöscht. Bitte versuche es erneut oder kontaktiere den Support.', 'logo_updated' => 'Logo erfolgreich hochgeladen!', 'logo_deleted' => 'Logo erfolgreich entfernt!', 'notifications_updated' => 'Benachrichtigungseinstellungen aktualisiert!', diff --git a/lang/el/auth.php b/lang/el/auth.php index e9b1e9f36..1e78eae9a 100644 --- a/lang/el/auth.php +++ b/lang/el/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'Συνδεθείτε ή δημιουργήστε λογαριασμό για να αποδεχτείτε αυτή την πρόσκληση.', 'log_in' => 'Σύνδεση', 'create_account' => 'Δημιουργία λογαριασμού', + 'expired_title' => 'Αυτή η πρόσκληση δεν ισχύει πλέον', + 'expired_description' => 'Το workspace αυτής της πρόσκλησης διαγράφηκε. Ζητήστε νέα πρόσκληση από τον κάτοχο του λογαριασμού αν χρειάζεστε ακόμα πρόσβαση.', + 'expired_action' => 'Αρχική σελίδα', ], ]; diff --git a/lang/el/settings.php b/lang/el/settings.php index 0a2a638ed..b6f089119 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Διαγραφή λογαριασμού', 'description' => 'Διαγράψτε τον λογαριασμό σας και όλους τους πόρους του', 'warning' => 'Προειδοποίηση', - 'warning_message' => 'Παρακαλούμε προχωρήστε με προσοχή, αυτό δεν μπορεί να αναιρεθεί.', + 'warning_message' => 'Προχωρήστε με προσοχή: αυτό δεν μπορεί να αναιρεθεί. Αν είστε κάτοχος αυτού του λογαριασμού, τα προσκεκλημένα μέλη θα διαγραφούν επίσης οριστικά.', 'button' => 'Διαγραφή λογαριασμού', 'modal_title' => 'Είστε βέβαιοι ότι θέλετε να διαγράψετε τον λογαριασμό σας;', - 'modal_description_password' => 'Μόλις διαγραφεί ο λογαριασμός σας, όλοι οι πόροι και τα δεδομένα του θα διαγραφούν επίσης οριστικά. Παρακαλούμε εισάγετε τον κωδικό πρόσβασής σας για επιβεβαίωση.', - 'modal_description_email' => 'Μόλις διαγραφεί ο λογαριασμός σας, όλοι οι πόροι και τα δεδομένα του θα διαγραφούν επίσης οριστικά. Παρακαλούμε πληκτρολογήστε τη διεύθυνση email σας :email για επιβεβαίωση.', + 'modal_description_password' => 'Μόλις διαγραφεί ο λογαριασμός σας, όλοι οι πόροι και τα δεδομένα του θα διαγραφούν επίσης οριστικά. Αν είστε κάτοχος του λογαριασμού, τα προσκεκλημένα μέλη θα διαγραφούν επίσης οριστικά. Παρακαλούμε εισάγετε τον κωδικό πρόσβασής σας για επιβεβαίωση.', + 'modal_description_email' => 'Μόλις διαγραφεί ο λογαριασμός σας, όλοι οι πόροι και τα δεδομένα του θα διαγραφούν επίσης οριστικά. Αν είστε κάτοχος του λογαριασμού, τα προσκεκλημένα μέλη θα διαγραφούν επίσης οριστικά. Παρακαλούμε πληκτρολογήστε τη διεύθυνση email σας :email για επιβεβαίωση.', 'password' => 'Κωδικός πρόσβασης', 'password_placeholder' => 'Κωδικός πρόσβασης', 'email_placeholder' => 'Το email του λογαριασμού σας', @@ -140,6 +140,20 @@ 'name' => 'Όνομα', 'name_placeholder' => 'Το workspace μου', 'save' => 'Αποθήκευση', + 'danger_description' => 'Μη αναστρέψιμες ενέργειες.', + 'delete_warning' => 'Προειδοποίηση', + 'delete_title' => 'Διαγραφή αυτού του workspace', + 'delete_description' => 'Διαγράφει οριστικά αυτό το workspace και ό,τι περιέχει — αναρτήσεις, συνδεδεμένους λογαριασμούς και μέσα. Θα χρεώνεστε για ένα λιγότερο workspace.', + 'delete_description_self_hosted' => 'Διαγράφει οριστικά αυτό το workspace, τις αναρτήσεις, τους συνδεδεμένους λογαριασμούς και τα μέσα.', + 'delete_only_description' => 'Δεν μπορείτε να διαγράψετε το μοναδικό σας workspace. Ακυρώστε τη συνδρομή στη χρέωση για να σταματήσετε να πληρώνετε, ή διαγράψτε τον λογαριασμό σας στην Επαλήθευση για να ακυρώσετε τη χρέωση και να αφαιρέσετε τα πάντα οριστικά.', + 'delete_go_to_billing' => 'Μετάβαση στη χρέωση', + 'delete_go_to_delete_account' => 'Διαγραφή λογαριασμού', + 'delete_members_warning' => '{1}:count άλλο μέλος θα χάσει την πρόσβαση. Τα μέλη χωρίς άλλο workspace στο TryPost θα διαγραφούν οριστικά.|[2,*]:count άλλα μέλη θα χάσουν την πρόσβαση. Τα μέλη χωρίς άλλο workspace στο TryPost θα διαγραφούν οριστικά.', + 'delete_action' => 'Διαγραφή workspace', + 'delete_cancel' => 'Ακύρωση', + 'delete_confirm_title' => 'Διαγραφή workspace;', + 'delete_confirm_description' => 'Αυτό διαγράφει οριστικά το workspace και όλα τα δεδομένα του. Η χρέωσή σας θα ενημερωθεί ώστε να αντικατοπτρίζει ένα λιγότερο workspace.', + 'delete_confirm_description_self_hosted' => 'Αυτό διαγράφει οριστικά το workspace και όλα τα δεδομένα του.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'Αυτή η πρόσκληση αφορά διαφορετική διεύθυνση email.', 'already_member' => 'Είστε ήδη μέλος αυτού του workspace.', 'invite_accepted' => 'Καλώς ήρθατε! Είστε πλέον μέλος του workspace.', + 'invite_workspace_gone' => 'Αυτή η πρόσκληση δεν ισχύει πλέον επειδή το workspace διαγράφηκε.', 'invite_declined' => 'Η πρόσκληση απορρίφθηκε.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'Οι ρυθμίσεις ενημερώθηκαν με επιτυχία!', 'photo_updated' => 'Η φωτογραφία ενημερώθηκε με επιτυχία!', 'photo_deleted' => 'Η φωτογραφία αφαιρέθηκε με επιτυχία!', + 'delete_failed_billing' => 'Δεν μπορέσαμε να ακυρώσουμε τη συνδρομή σας στον πάροχο χρέωσης. Δεν διαγράφηκε τίποτα. Δοκιμάστε ξανά ή επικοινωνήστε με την υποστήριξη.', 'logo_updated' => 'Το λογότυπο μεταφορτώθηκε με επιτυχία!', 'logo_deleted' => 'Το λογότυπο αφαιρέθηκε με επιτυχία!', 'notifications_updated' => 'Οι προτιμήσεις ειδοποιήσεων ενημερώθηκαν!', diff --git a/lang/en/auth.php b/lang/en/auth.php index 18dd53c10..c4027a4f2 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'Log in or create an account to accept this invite.', 'log_in' => 'Log in', 'create_account' => 'Create Account', + 'expired_title' => 'This invite is no longer valid', + 'expired_description' => 'The workspace for this invite was deleted. Ask the account owner for a new invite if you still need access.', + 'expired_action' => 'Go to home', ], ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 94858730c..8ac2e138f 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Delete account', 'description' => 'Delete your account and all of its resources', 'warning' => 'Warning', - 'warning_message' => 'Please proceed with caution, this cannot be undone.', + 'warning_message' => 'Please proceed with caution, this cannot be undone. If you own this account, invited members will also be permanently deleted.', 'button' => 'Delete account', 'modal_title' => 'Are you sure you want to delete your account?', - 'modal_description_password' => 'Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm.', - 'modal_description_email' => 'Once your account is deleted, all of its resources and data will also be permanently deleted. Please type your email address :email to confirm.', + 'modal_description_password' => 'Once your account is deleted, all of its resources and data will also be permanently deleted. If you own the account, invited members will be permanently deleted too. Please enter your password to confirm.', + 'modal_description_email' => 'Once your account is deleted, all of its resources and data will also be permanently deleted. If you own the account, invited members will be permanently deleted too. Please type your email address :email to confirm.', 'password' => 'Password', 'password_placeholder' => 'Password', 'email_placeholder' => 'Your account email', @@ -140,6 +140,20 @@ 'name' => 'Name', 'name_placeholder' => 'My Workspace', 'save' => 'Save', + 'danger_description' => 'Irreversible actions.', + 'delete_warning' => 'Warning', + 'delete_title' => 'Delete this workspace', + 'delete_description' => 'Permanently deletes this workspace and everything in it — posts, connected accounts, and media. You will be charged for one fewer workspace.', + 'delete_description_self_hosted' => 'Permanently deletes this workspace, its posts, connected accounts, and media.', + 'delete_only_description' => 'You cannot delete your only workspace. Cancel your subscription in billing to stop paying, or delete your account in Authentication settings to cancel billing and permanently remove everything.', + 'delete_go_to_billing' => 'Go to billing', + 'delete_go_to_delete_account' => 'Delete account', + 'delete_members_warning' => '{1}:count other member will lose access. Members without another TryPost workspace are permanently deleted.|[2,*]:count other members will lose access. Members without another TryPost workspace are permanently deleted.', + 'delete_action' => 'Delete workspace', + 'delete_cancel' => 'Cancel', + 'delete_confirm_title' => 'Delete workspace?', + 'delete_confirm_description' => 'This permanently deletes the workspace and all of its data. Your billing will update to reflect one fewer workspace.', + 'delete_confirm_description_self_hosted' => 'This permanently deletes the workspace and all of its data.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'This invite is for a different email address.', 'already_member' => 'You are already a member of this workspace.', 'invite_accepted' => 'Welcome! You are now a member of the workspace.', + 'invite_workspace_gone' => 'This invite is no longer valid because the workspace was deleted.', 'invite_declined' => 'Invite declined.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'Settings updated successfully!', 'photo_updated' => 'Photo updated successfully!', 'photo_deleted' => 'Photo removed successfully!', + 'delete_failed_billing' => 'We could not cancel your subscription with the billing provider. Nothing was deleted. Please try again or contact support.', 'logo_updated' => 'Logo uploaded successfully!', 'logo_deleted' => 'Logo removed successfully!', 'notifications_updated' => 'Notification preferences updated!', diff --git a/lang/es/auth.php b/lang/es/auth.php index 4acdad969..2675b6cac 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -124,5 +124,8 @@ 'login_prompt' => 'Inicia sesión o crea una cuenta para aceptar esta invitación.', 'log_in' => 'Iniciar sesión', 'create_account' => 'Crear cuenta', + 'expired_title' => 'Esta invitación ya no es válida', + 'expired_description' => 'El workspace de esta invitación fue eliminado. Pide al propietario de la cuenta una nueva invitación si aún necesitas acceso.', + 'expired_action' => 'Ir al inicio', ], ]; diff --git a/lang/es/settings.php b/lang/es/settings.php index 34053b17c..bb27bb775 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Eliminar cuenta', 'description' => 'Elimina tu cuenta y todos sus recursos', 'warning' => 'Advertencia', - 'warning_message' => 'Procede con precaución, esta acción no se puede deshacer.', + 'warning_message' => 'Procede con cuidado: esto no se puede deshacer. Si eres el propietario de esta cuenta, los miembros invitados también se eliminarán de forma permanente.', 'button' => 'Eliminar cuenta', 'modal_title' => '¿Estás seguro de que deseas eliminar tu cuenta?', - 'modal_description_password' => 'Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Introduce tu contraseña para confirmar.', - 'modal_description_email' => 'Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Escribe tu correo :email para confirmar.', + 'modal_description_password' => 'Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Si eres el propietario de la cuenta, los miembros invitados también se eliminarán de forma permanente. Introduce tu contraseña para confirmar.', + 'modal_description_email' => 'Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Si eres el propietario de la cuenta, los miembros invitados también se eliminarán de forma permanente. Escribe tu correo :email para confirmar.', 'password' => 'Contraseña', 'password_placeholder' => 'Contraseña', 'email_placeholder' => 'Tu correo', @@ -140,6 +140,20 @@ 'name' => 'Nombre', 'name_placeholder' => 'Mi Workspace', 'save' => 'Guardar', + 'danger_description' => 'Acciones irreversibles.', + 'delete_warning' => 'Advertencia', + 'delete_title' => 'Eliminar este workspace', + 'delete_description' => 'Elimina este workspace de forma permanente, incluyendo publicaciones, cuentas conectadas y medios. Pasarás a pagar por un workspace menos.', + 'delete_description_self_hosted' => 'Elimina permanentemente este workspace, sus publicaciones, cuentas conectadas y medios.', + 'delete_only_description' => 'No puedes eliminar tu único workspace. Cancela la suscripción en facturación para dejar de pagar, o elimina tu cuenta en Autenticación para cancelar la facturación y borrar todo de forma permanente.', + 'delete_go_to_billing' => 'Ir a facturación', + 'delete_go_to_delete_account' => 'Eliminar cuenta', + 'delete_members_warning' => '{1}:count miembro más perderá el acceso. Los miembros sin otro workspace en TryPost se eliminarán de forma permanente.|[2,*]:count miembros más perderán el acceso. Los miembros sin otro workspace en TryPost se eliminarán de forma permanente.', + 'delete_action' => 'Eliminar workspace', + 'delete_cancel' => 'Cancelar', + 'delete_confirm_title' => '¿Eliminar workspace?', + 'delete_confirm_description' => 'Esto elimina permanentemente el workspace y todos sus datos. Tu facturación se actualizará para reflejar un workspace menos.', + 'delete_confirm_description_self_hosted' => 'Esto elimina permanentemente el workspace y todos sus datos.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'Esta invitación es para otro correo electrónico.', 'already_member' => 'Ya eres miembro de este workspace.', 'invite_accepted' => '¡Bienvenido! Ahora eres miembro del workspace.', + 'invite_workspace_gone' => 'Esta invitación ya no es válida porque el workspace fue eliminado.', 'invite_declined' => 'Invitación rechazada.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => '¡Configuración actualizada correctamente!', 'photo_updated' => '¡Foto actualizada correctamente!', 'photo_deleted' => '¡Foto eliminada correctamente!', + 'delete_failed_billing' => 'No pudimos cancelar tu suscripción con el proveedor de facturación. No se eliminó nada. Inténtalo de nuevo o contacta con soporte.', 'logo_updated' => '¡Logo subido correctamente!', 'logo_deleted' => '¡Logo eliminado correctamente!', 'notifications_updated' => '¡Preferencias de notificaciones actualizadas!', diff --git a/lang/fr/auth.php b/lang/fr/auth.php index a06b48642..6a71cdb78 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'Connectez-vous ou créez un compte pour accepter cette invitation.', 'log_in' => 'Se connecter', 'create_account' => 'Créer un compte', + 'expired_title' => 'Cette invitation n’est plus valide', + 'expired_description' => 'L’espace de travail de cette invitation a été supprimé. Demandez une nouvelle invitation au propriétaire du compte si vous avez encore besoin d’accès.', + 'expired_action' => 'Retour à l’accueil', ], ]; diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 83e092baf..98052a0e8 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Supprimer le compte', 'description' => 'Supprimez votre compte et toutes ses ressources', 'warning' => 'Avertissement', - 'warning_message' => 'Veuillez procéder avec prudence, cette action est irréversible.', + 'warning_message' => 'Procédez avec prudence : cette action est irréversible. Si vous êtes propriétaire de ce compte, les membres invités seront aussi définitivement supprimés.', 'button' => 'Supprimer le compte', 'modal_title' => 'Voulez-vous vraiment supprimer votre compte ?', - 'modal_description_password' => 'Une fois votre compte supprimé, toutes ses ressources et données seront également supprimées définitivement. Veuillez saisir votre mot de passe pour confirmer.', - 'modal_description_email' => 'Une fois votre compte supprimé, toutes ses ressources et données seront également supprimées définitivement. Veuillez saisir votre adresse e-mail :email pour confirmer.', + 'modal_description_password' => 'Une fois votre compte supprimé, toutes ses ressources et données seront également supprimées définitivement. Si vous êtes propriétaire du compte, les membres invités seront aussi définitivement supprimés. Veuillez saisir votre mot de passe pour confirmer.', + 'modal_description_email' => 'Une fois votre compte supprimé, toutes ses ressources et données seront également supprimées définitivement. Si vous êtes propriétaire du compte, les membres invités seront aussi définitivement supprimés. Veuillez saisir votre adresse e-mail :email pour confirmer.', 'password' => 'Mot de passe', 'password_placeholder' => 'Mot de passe', 'email_placeholder' => 'L\'e-mail de votre compte', @@ -140,6 +140,20 @@ 'name' => 'Nom', 'name_placeholder' => 'Mon espace de travail', 'save' => 'Enregistrer', + 'danger_description' => 'Actions irréversibles.', + 'delete_warning' => 'Avertissement', + 'delete_title' => 'Supprimer cet espace de travail', + 'delete_description' => 'Supprime définitivement cet espace de travail et tout son contenu — publications, comptes connectés et médias. Vous serez facturé pour un espace de travail de moins.', + 'delete_description_self_hosted' => 'Supprime définitivement cet espace de travail, ses publications, comptes connectés et médias.', + 'delete_only_description' => 'Vous ne pouvez pas supprimer votre seul espace de travail. Annulez l’abonnement dans la facturation pour arrêter de payer, ou supprimez votre compte dans Authentification pour annuler la facturation et tout supprimer définitivement.', + 'delete_go_to_billing' => 'Aller à la facturation', + 'delete_go_to_delete_account' => 'Supprimer le compte', + 'delete_members_warning' => '{1}:count autre membre perdra l’accès. Les membres sans autre workspace TryPost seront définitivement supprimés.|[2,*]:count autres membres perdront l’accès. Les membres sans autre workspace TryPost seront définitivement supprimés.', + 'delete_action' => 'Supprimer l’espace de travail', + 'delete_cancel' => 'Annuler', + 'delete_confirm_title' => 'Supprimer l’espace de travail ?', + 'delete_confirm_description' => 'Cela supprime définitivement l’espace de travail et toutes ses données. Votre facturation sera mise à jour pour refléter un espace de travail en moins.', + 'delete_confirm_description_self_hosted' => 'Cela supprime définitivement l’espace de travail et toutes ses données.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'Cette invitation concerne une autre adresse e-mail.', 'already_member' => 'Vous êtes déjà membre de cet espace de travail.', 'invite_accepted' => 'Bienvenue ! Vous êtes maintenant membre de l\'espace de travail.', + 'invite_workspace_gone' => 'Cette invitation n’est plus valide car l’espace de travail a été supprimé.', 'invite_declined' => 'Invitation refusée.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'Paramètres mis à jour avec succès !', 'photo_updated' => 'Photo mise à jour avec succès !', 'photo_deleted' => 'Photo supprimée avec succès !', + 'delete_failed_billing' => 'Nous n’avons pas pu annuler votre abonnement auprès du prestataire de facturation. Rien n’a été supprimé. Réessayez ou contactez le support.', 'logo_updated' => 'Logo importé avec succès !', 'logo_deleted' => 'Logo supprimé avec succès !', 'notifications_updated' => 'Préférences de notification mises à jour !', diff --git a/lang/it/auth.php b/lang/it/auth.php index f60925a2f..67ac95b3c 100644 --- a/lang/it/auth.php +++ b/lang/it/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'Accedi o crea un account per accettare questo invito.', 'log_in' => 'Accedi', 'create_account' => 'Crea account', + 'expired_title' => 'Questo invito non è più valido', + 'expired_description' => 'Il workspace di questo invito è stato eliminato. Chiedi al proprietario dell’account un nuovo invito se ti serve ancora l’accesso.', + 'expired_action' => 'Vai alla home', ], ]; diff --git a/lang/it/settings.php b/lang/it/settings.php index e34c3e9d3..06053c516 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Elimina account', 'description' => 'Elimina il tuo account e tutte le sue risorse', 'warning' => 'Attenzione', - 'warning_message' => 'Procedi con cautela, questa azione non può essere annullata.', + 'warning_message' => 'Procedi con cautela: questa azione non può essere annullata. Se sei il proprietario di questo account, anche i membri invitati verranno eliminati in modo permanente.', 'button' => 'Elimina account', 'modal_title' => 'Vuoi davvero eliminare il tuo account?', - 'modal_description_password' => 'Una volta eliminato il tuo account, tutte le sue risorse e i suoi dati saranno eliminati definitivamente. Inserisci la tua password per confermare.', - 'modal_description_email' => 'Una volta eliminato il tuo account, tutte le sue risorse e i suoi dati saranno eliminati definitivamente. Digita il tuo indirizzo email :email per confermare.', + 'modal_description_password' => 'Una volta eliminato il tuo account, tutte le sue risorse e i suoi dati saranno eliminati definitivamente. Se sei il proprietario dell’account, anche i membri invitati verranno eliminati in modo permanente. Inserisci la tua password per confermare.', + 'modal_description_email' => 'Una volta eliminato il tuo account, tutte le sue risorse e i suoi dati saranno eliminati definitivamente. Se sei il proprietario dell’account, anche i membri invitati verranno eliminati in modo permanente. Digita il tuo indirizzo email :email per confermare.', 'password' => 'Password', 'password_placeholder' => 'Password', 'email_placeholder' => 'L\'email del tuo account', @@ -140,6 +140,20 @@ 'name' => 'Nome', 'name_placeholder' => 'Il mio workspace', 'save' => 'Salva', + 'danger_description' => 'Azioni irreversibili.', + 'delete_warning' => 'Attenzione', + 'delete_title' => 'Elimina questo workspace', + 'delete_description' => 'Elimina definitivamente questo workspace e tutto il suo contenuto — post, account collegati e media. Verrai addebitato per un workspace in meno.', + 'delete_description_self_hosted' => 'Elimina definitivamente questo workspace, i suoi post, gli account collegati e i media.', + 'delete_only_description' => 'Non puoi eliminare il tuo unico workspace. Annulla l’abbonamento nella fatturazione per smettere di pagare, oppure elimina il tuo account in Autenticazione per annullare la fatturazione e rimuovere tutto in modo permanente.', + 'delete_go_to_billing' => 'Vai alla fatturazione', + 'delete_go_to_delete_account' => 'Elimina account', + 'delete_members_warning' => '{1}:count altro membro perderà l’accesso. I membri senza un altro workspace TryPost verranno eliminati in modo permanente.|[2,*]:count altri membri perderanno l’accesso. I membri senza un altro workspace TryPost verranno eliminati in modo permanente.', + 'delete_action' => 'Elimina workspace', + 'delete_cancel' => 'Annulla', + 'delete_confirm_title' => 'Eliminare il workspace?', + 'delete_confirm_description' => 'Questo elimina definitivamente il workspace e tutti i suoi dati. La fatturazione verrà aggiornata per riflettere un workspace in meno.', + 'delete_confirm_description_self_hosted' => 'Questo elimina definitivamente il workspace e tutti i suoi dati.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'Questo invito è per un indirizzo email diverso.', 'already_member' => 'Sei già membro di questo workspace.', 'invite_accepted' => 'Benvenuto! Ora sei membro del workspace.', + 'invite_workspace_gone' => 'Questo invito non è più valido perché il workspace è stato eliminato.', 'invite_declined' => 'Invito rifiutato.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'Impostazioni aggiornate con successo!', 'photo_updated' => 'Foto aggiornata con successo!', 'photo_deleted' => 'Foto rimossa con successo!', + 'delete_failed_billing' => 'Non siamo riusciti a cancellare l’abbonamento presso il fornitore di fatturazione. Non è stato eliminato nulla. Riprova o contatta l’assistenza.', 'logo_updated' => 'Logo caricato con successo!', 'logo_deleted' => 'Logo rimosso con successo!', 'notifications_updated' => 'Preferenze di notifica aggiornate!', diff --git a/lang/ja/auth.php b/lang/ja/auth.php index 9ddb7627e..252e49553 100644 --- a/lang/ja/auth.php +++ b/lang/ja/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'この招待を承諾するには、ログインまたはアカウントを作成してください。', 'log_in' => 'ログイン', 'create_account' => 'アカウントを作成', + 'expired_title' => 'この招待は無効です', + 'expired_description' => 'この招待のワークスペースは削除されました。引き続きアクセスが必要な場合は、アカウント所有者に新しい招待を依頼してください。', + 'expired_action' => 'ホームへ', ], ]; diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 80eae74b4..97cd91ce3 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -110,11 +110,11 @@ 'heading' => 'アカウントを削除', 'description' => 'アカウントとそのすべてのリソースを削除します', 'warning' => '警告', - 'warning_message' => '慎重に進めてください。この操作は取り消せません。', + 'warning_message' => 'ご注意ください。この操作は取り消せません。このアカウントの所有者である場合、招待されたメンバーも完全に削除されます。', 'button' => 'アカウントを削除', 'modal_title' => 'アカウントを削除してもよろしいですか?', - 'modal_description_password' => 'アカウントを削除すると、そのすべてのリソースとデータも完全に削除されます。確認のためパスワードを入力してください。', - 'modal_description_email' => 'アカウントを削除すると、そのすべてのリソースとデータも完全に削除されます。確認のためメールアドレス :email を入力してください。', + 'modal_description_password' => 'アカウントを削除すると、そのすべてのリソースとデータも完全に削除されます。このアカウントの所有者である場合、招待されたメンバーも完全に削除されます。確認のためパスワードを入力してください。', + 'modal_description_email' => 'アカウントを削除すると、そのすべてのリソースとデータも完全に削除されます。このアカウントの所有者である場合、招待されたメンバーも完全に削除されます。確認のためメールアドレス :email を入力してください。', 'password' => 'パスワード', 'password_placeholder' => 'パスワード', 'email_placeholder' => 'アカウントのメールアドレス', @@ -140,6 +140,20 @@ 'name' => '名前', 'name_placeholder' => 'マイワークスペース', 'save' => '保存', + 'danger_description' => '元に戻せない操作です。', + 'delete_warning' => '警告', + 'delete_title' => 'このワークスペースを削除', + 'delete_description' => 'このワークスペースと、投稿・連携アカウント・メディアを含むすべての内容を完全に削除します。請求はワークスペースが1つ減った分になります。', + 'delete_description_self_hosted' => 'このワークスペースと投稿、連携アカウント、メディアを完全に削除します。', + 'delete_only_description' => '唯一のワークスペースは削除できません。支払いを止めるには請求設定でサブスクリプションを解約するか、認証設定でアカウントを削除して請求を止め、すべてを完全に削除してください。', + 'delete_go_to_billing' => '請求へ移動', + 'delete_go_to_delete_account' => 'アカウントを削除', + 'delete_members_warning' => '{1}他のメンバー :count 人がアクセスできなくなります。別のTryPostワークスペースがないメンバーのアカウントは完全に削除されます。|[2,*]他のメンバー :count 人がアクセスできなくなります。別のTryPostワークスペースがないメンバーのアカウントは完全に削除されます。', + 'delete_action' => 'ワークスペースを削除', + 'delete_cancel' => 'キャンセル', + 'delete_confirm_title' => 'ワークスペースを削除しますか?', + 'delete_confirm_description' => 'ワークスペースとそのすべてのデータが完全に削除されます。請求はワークスペースが1つ減った内容に更新されます。', + 'delete_confirm_description_self_hosted' => 'ワークスペースとそのすべてのデータが完全に削除されます。', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'この招待は別のメールアドレス宛てです。', 'already_member' => 'あなたはすでにこのワークスペースのメンバーです。', 'invite_accepted' => 'ようこそ!これでワークスペースのメンバーになりました。', + 'invite_workspace_gone' => 'ワークスペースが削除されたため、この招待は無効です。', 'invite_declined' => '招待を辞退しました。', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => '設定を正常に更新しました!', 'photo_updated' => '写真を正常に更新しました!', 'photo_deleted' => '写真を正常に削除しました!', + 'delete_failed_billing' => '請求プロバイダーでのサブスクリプションの解約に失敗しました。何も削除されていません。もう一度お試しいただくか、サポートまでご連絡ください。', 'logo_updated' => 'ロゴを正常にアップロードしました!', 'logo_deleted' => 'ロゴを正常に削除しました!', 'notifications_updated' => '通知設定を更新しました!', diff --git a/lang/ko/auth.php b/lang/ko/auth.php index 4220afecf..a86708dad 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => '이 초대를 수락하려면 로그인하거나 계정을 만드세요.', 'log_in' => '로그인', 'create_account' => '계정 만들기', + 'expired_title' => '이 초대는 더 이상 유효하지 않습니다', + 'expired_description' => '이 초대의 워크스페이스가 삭제되었습니다. 계속 접근이 필요하면 계정 소유자에게 새 초대를 요청하세요.', + 'expired_action' => '홈으로', ], ]; diff --git a/lang/ko/settings.php b/lang/ko/settings.php index feb6bf182..7c98d07ba 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -110,11 +110,11 @@ 'heading' => '계정 삭제', 'description' => '계정과 모든 리소스를 삭제하세요', 'warning' => '경고', - 'warning_message' => '주의하여 진행하세요. 이 작업은 되돌릴 수 없습니다.', + 'warning_message' => '신중히 진행하세요. 이 작업은 되돌릴 수 없습니다. 이 계정의 소유자라면 초대된 멤버도 영구 삭제됩니다.', 'button' => '계정 삭제', 'modal_title' => '정말 계정을 삭제하시겠습니까?', - 'modal_description_password' => '계정이 삭제되면 모든 리소스와 데이터도 영구적으로 삭제됩니다. 확인하려면 비밀번호를 입력하세요.', - 'modal_description_email' => '계정이 삭제되면 모든 리소스와 데이터도 영구적으로 삭제됩니다. 확인하려면 이메일 주소 :email을(를) 입력하세요.', + 'modal_description_password' => '계정이 삭제되면 모든 리소스와 데이터도 영구적으로 삭제됩니다. 이 계정의 소유자라면 초대된 멤버도 영구 삭제됩니다. 확인하려면 비밀번호를 입력하세요.', + 'modal_description_email' => '계정이 삭제되면 모든 리소스와 데이터도 영구적으로 삭제됩니다. 이 계정의 소유자라면 초대된 멤버도 영구 삭제됩니다. 확인하려면 이메일 주소 :email을(를) 입력하세요.', 'password' => '비밀번호', 'password_placeholder' => '비밀번호', 'email_placeholder' => '계정 이메일', @@ -140,6 +140,20 @@ 'name' => '이름', 'name_placeholder' => '내 워크스페이스', 'save' => '저장', + 'danger_description' => '되돌릴 수 없는 작업입니다.', + 'delete_warning' => '경고', + 'delete_title' => '이 워크스페이스 삭제', + 'delete_description' => '이 워크스페이스와 그 안의 모든 것(게시물, 연결된 계정, 미디어)을 영구 삭제합니다. 워크스페이스 하나만큼 요금이 줄어듭니다.', + 'delete_description_self_hosted' => '이 워크스페이스와 게시물, 연결된 계정, 미디어를 영구 삭제합니다.', + 'delete_only_description' => '유일한 워크스페이스는 삭제할 수 없습니다. 결제를 멈추려면 청구에서 구독을 취소하거나, 인증 설정에서 계정을 삭제해 결제를 취소하고 모든 데이터를 영구 삭제하세요.', + 'delete_go_to_billing' => '결제로 이동', + 'delete_go_to_delete_account' => '계정 삭제', + 'delete_members_warning' => '{1}다른 멤버 :count명이 접근 권한을 잃습니다. 다른 TryPost 워크스페이스가 없는 멤버는 계정이 영구 삭제됩니다.|[2,*]다른 멤버 :count명이 접근 권한을 잃습니다. 다른 TryPost 워크스페이스가 없는 멤버는 계정이 영구 삭제됩니다.', + 'delete_action' => '워크스페이스 삭제', + 'delete_cancel' => '취소', + 'delete_confirm_title' => '워크스페이스를 삭제할까요?', + 'delete_confirm_description' => '워크스페이스와 모든 데이터가 영구 삭제됩니다. 결제는 워크스페이스가 하나 줄어든 상태로 업데이트됩니다.', + 'delete_confirm_description_self_hosted' => '워크스페이스와 모든 데이터가 영구 삭제됩니다.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => '이 초대는 다른 이메일 주소를 위한 것입니다.', 'already_member' => '이미 이 워크스페이스의 멤버입니다.', 'invite_accepted' => '환영합니다! 이제 워크스페이스의 멤버입니다.', + 'invite_workspace_gone' => '워크스페이스가 삭제되어 이 초대는 더 이상 유효하지 않습니다.', 'invite_declined' => '초대를 거절했습니다.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => '설정이 성공적으로 업데이트되었습니다!', 'photo_updated' => '사진이 성공적으로 업데이트되었습니다!', 'photo_deleted' => '사진이 성공적으로 제거되었습니다!', + 'delete_failed_billing' => '결제 제공업체에서 구독을 취소하지 못했습니다. 아무것도 삭제되지 않았습니다. 다시 시도하거나 지원팀에 문의하세요.', 'logo_updated' => '로고가 성공적으로 업로드되었습니다!', 'logo_deleted' => '로고가 성공적으로 제거되었습니다!', 'notifications_updated' => '알림 설정이 업데이트되었습니다!', diff --git a/lang/nl/auth.php b/lang/nl/auth.php index b1cbaaa3f..a12ae71cf 100644 --- a/lang/nl/auth.php +++ b/lang/nl/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'Log in of maak een account aan om deze uitnodiging te accepteren.', 'log_in' => 'Inloggen', 'create_account' => 'Account aanmaken', + 'expired_title' => 'Deze uitnodiging is niet meer geldig', + 'expired_description' => 'De workspace van deze uitnodiging is verwijderd. Vraag de accounteigenaar om een nieuwe uitnodiging als je nog toegang nodig hebt.', + 'expired_action' => 'Naar home', ], ]; diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 6a5b42a8f..19ccdf2dc 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Account verwijderen', 'description' => 'Verwijder je account en alle bijbehorende gegevens', 'warning' => 'Waarschuwing', - 'warning_message' => 'Ga voorzichtig te werk, dit kan niet ongedaan worden gemaakt.', + 'warning_message' => 'Ga voorzichtig te werk: dit kan niet ongedaan worden gemaakt. Als je de eigenaar van dit account bent, worden uitgenodigde leden ook permanent verwijderd.', 'button' => 'Account verwijderen', 'modal_title' => 'Weet je zeker dat je je account wilt verwijderen?', - 'modal_description_password' => 'Zodra je account is verwijderd, worden ook alle bijbehorende gegevens permanent verwijderd. Voer je wachtwoord in om te bevestigen.', - 'modal_description_email' => 'Zodra je account is verwijderd, worden ook alle bijbehorende gegevens permanent verwijderd. Typ je e-mailadres :email om te bevestigen.', + 'modal_description_password' => 'Zodra je account is verwijderd, worden ook alle bijbehorende gegevens permanent verwijderd. Als je de eigenaar van het account bent, worden uitgenodigde leden ook permanent verwijderd. Voer je wachtwoord in om te bevestigen.', + 'modal_description_email' => 'Zodra je account is verwijderd, worden ook alle bijbehorende gegevens permanent verwijderd. Als je de eigenaar van het account bent, worden uitgenodigde leden ook permanent verwijderd. Typ je e-mailadres :email om te bevestigen.', 'password' => 'Wachtwoord', 'password_placeholder' => 'Wachtwoord', 'email_placeholder' => 'Je account-e-mailadres', @@ -140,6 +140,20 @@ 'name' => 'Naam', 'name_placeholder' => 'Mijn workspace', 'save' => 'Opslaan', + 'danger_description' => 'Onomkeerbare acties.', + 'delete_warning' => 'Waarschuwing', + 'delete_title' => 'Deze workspace verwijderen', + 'delete_description' => 'Verwijdert deze workspace permanent, inclusief posts, gekoppelde accounts en media. Je wordt voor één workspace minder gefactureerd.', + 'delete_description_self_hosted' => 'Verwijdert deze workspace permanent, inclusief posts, gekoppelde accounts en media.', + 'delete_only_description' => 'Je kunt je enige workspace niet verwijderen. Zeg je abonnement op in facturatie om te stoppen met betalen, of verwijder je account bij Authenticatie om de facturatie te annuleren en alles permanent te wissen.', + 'delete_go_to_billing' => 'Naar facturatie', + 'delete_go_to_delete_account' => 'Account verwijderen', + 'delete_members_warning' => '{1}:count ander lid verliest toegang. Leden zonder andere TryPost-workspace worden permanent verwijderd.|[2,*]:count andere leden verliezen toegang. Leden zonder andere TryPost-workspace worden permanent verwijderd.', + 'delete_action' => 'Workspace verwijderen', + 'delete_cancel' => 'Annuleren', + 'delete_confirm_title' => 'Workspace verwijderen?', + 'delete_confirm_description' => 'Dit verwijdert de workspace en al zijn gegevens permanent. Je facturatie wordt bijgewerkt met één workspace minder.', + 'delete_confirm_description_self_hosted' => 'Dit verwijdert de workspace en al zijn gegevens permanent.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'Deze uitnodiging is voor een ander e-mailadres.', 'already_member' => 'Je bent al lid van deze workspace.', 'invite_accepted' => 'Welkom! Je bent nu lid van de workspace.', + 'invite_workspace_gone' => 'Deze uitnodiging is niet meer geldig omdat de workspace is verwijderd.', 'invite_declined' => 'Uitnodiging geweigerd.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'Instellingen succesvol bijgewerkt!', 'photo_updated' => 'Foto succesvol bijgewerkt!', 'photo_deleted' => 'Foto succesvol verwijderd!', + 'delete_failed_billing' => 'We konden je abonnement bij de betalingsprovider niet annuleren. Er is niets verwijderd. Probeer het opnieuw of neem contact op met support.', 'logo_updated' => 'Logo succesvol geüpload!', 'logo_deleted' => 'Logo succesvol verwijderd!', 'notifications_updated' => 'Meldingsvoorkeuren bijgewerkt!', diff --git a/lang/pl/auth.php b/lang/pl/auth.php index 12442ee6a..056b66678 100644 --- a/lang/pl/auth.php +++ b/lang/pl/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'Zaloguj się lub załóż konto, aby zaakceptować to zaproszenie.', 'log_in' => 'Zaloguj się', 'create_account' => 'Utwórz konto', + 'expired_title' => 'To zaproszenie jest już nieważne', + 'expired_description' => 'Przestrzeń robocza z tego zaproszenia została usunięta. Poproś właściciela konta o nowe zaproszenie, jeśli nadal potrzebujesz dostępu.', + 'expired_action' => 'Przejdź do strony głównej', ], ]; diff --git a/lang/pl/settings.php b/lang/pl/settings.php index bfd2db6c4..94f8bce78 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Usuń konto', 'description' => 'Usuń swoje konto i wszystkie jego zasoby', 'warning' => 'Ostrzeżenie', - 'warning_message' => 'Zachowaj ostrożność, tej operacji nie można cofnąć.', + 'warning_message' => 'Postępuj ostrożnie: tego nie da się cofnąć. Jeśli jesteś właścicielem tego konta, zaproszeni członkowie również zostaną trwale usunięci.', 'button' => 'Usuń konto', 'modal_title' => 'Czy na pewno chcesz usunąć swoje konto?', - 'modal_description_password' => 'Po usunięciu konta wszystkie jego zasoby i dane również zostaną trwale usunięte. Wprowadź hasło, aby potwierdzić.', - 'modal_description_email' => 'Po usunięciu konta wszystkie jego zasoby i dane również zostaną trwale usunięte. Wpisz swój adres e-mail :email, aby potwierdzić.', + 'modal_description_password' => 'Po usunięciu konta wszystkie jego zasoby i dane również zostaną trwale usunięte. Jeśli jesteś właścicielem konta, zaproszeni członkowie również zostaną trwale usunięci. Wprowadź hasło, aby potwierdzić.', + 'modal_description_email' => 'Po usunięciu konta wszystkie jego zasoby i dane również zostaną trwale usunięte. Jeśli jesteś właścicielem konta, zaproszeni członkowie również zostaną trwale usunięci. Wpisz swój adres e-mail :email, aby potwierdzić.', 'password' => 'Hasło', 'password_placeholder' => 'Hasło', 'email_placeholder' => 'E-mail Twojego konta', @@ -140,6 +140,20 @@ 'name' => 'Nazwa', 'name_placeholder' => 'Moja przestrzeń robocza', 'save' => 'Zapisz', + 'danger_description' => 'Nieodwracalne działania.', + 'delete_warning' => 'Ostrzeżenie', + 'delete_title' => 'Usuń ten workspace', + 'delete_description' => 'Trwale usuwa ten workspace wraz z postami, połączonymi kontami i mediami. Będziesz rozliczany za o jeden workspace mniej.', + 'delete_description_self_hosted' => 'Trwale usuwa ten workspace, jego posty, połączone konta i media.', + 'delete_only_description' => 'Nie możesz usunąć swojego jedynego workspace. Anuluj subskrypcję w rozliczeniach, aby przestać płacić, albo usuń konto w Uwierzytelnianiu, aby anulować rozliczenia i trwale usunąć wszystko.', + 'delete_go_to_billing' => 'Przejdź do rozliczeń', + 'delete_go_to_delete_account' => 'Usuń konto', + 'delete_members_warning' => '{1}:count inny członek straci dostęp. Członkowie bez innego workspace w TryPost zostaną trwale usunięci.|[2,*]:count innych członków straci dostęp. Członkowie bez innego workspace w TryPost zostaną trwale usunięci.', + 'delete_action' => 'Usuń workspace', + 'delete_cancel' => 'Anuluj', + 'delete_confirm_title' => 'Usunąć workspace?', + 'delete_confirm_description' => 'To trwale usuwa workspace i wszystkie jego dane. Rozliczenia zostaną zaktualizowane, aby odzwierciedlić o jeden workspace mniej.', + 'delete_confirm_description_self_hosted' => 'To trwale usuwa workspace i wszystkie jego dane.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'To zaproszenie dotyczy innego adresu e-mail.', 'already_member' => 'Jesteś już członkiem tej przestrzeni roboczej.', 'invite_accepted' => 'Witamy! Jesteś teraz członkiem przestrzeni roboczej.', + 'invite_workspace_gone' => 'To zaproszenie jest już nieważne, ponieważ przestrzeń robocza została usunięta.', 'invite_declined' => 'Zaproszenie odrzucone.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'Ustawienia zostały pomyślnie zaktualizowane!', 'photo_updated' => 'Zdjęcie zostało pomyślnie zaktualizowane!', 'photo_deleted' => 'Zdjęcie zostało pomyślnie usunięte!', + 'delete_failed_billing' => 'Nie udało się anulować subskrypcji u dostawcy płatności. Nic nie zostało usunięte. Spróbuj ponownie lub skontaktuj się z pomocą.', 'logo_updated' => 'Logo zostało pomyślnie przesłane!', 'logo_deleted' => 'Logo zostało pomyślnie usunięte!', 'notifications_updated' => 'Preferencje powiadomień zostały zaktualizowane!', diff --git a/lang/pt-BR/auth.php b/lang/pt-BR/auth.php index 6ce4dcb46..70725a657 100644 --- a/lang/pt-BR/auth.php +++ b/lang/pt-BR/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'Entre ou crie uma conta para aceitar este convite.', 'log_in' => 'Entrar', 'create_account' => 'Criar Conta', + 'expired_title' => 'Este convite não é mais válido', + 'expired_description' => 'O workspace deste convite foi excluído. Peça ao dono da conta um novo convite se ainda precisar de acesso.', + 'expired_action' => 'Ir para o início', ], ]; diff --git a/lang/pt-BR/settings.php b/lang/pt-BR/settings.php index 7ad405879..7865c868e 100644 --- a/lang/pt-BR/settings.php +++ b/lang/pt-BR/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Excluir conta', 'description' => 'Exclua sua conta e todos os seus recursos', 'warning' => 'Atenção', - 'warning_message' => 'Por favor, prossiga com cuidado, isso não pode ser desfeito.', + 'warning_message' => 'Continue com cuidado: isso não pode ser desfeito. Se você for o dono desta conta, os membros convidados também serão excluídos permanentemente.', 'button' => 'Excluir conta', 'modal_title' => 'Tem certeza que deseja excluir sua conta?', - 'modal_description_password' => 'Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Digite sua senha para confirmar.', - 'modal_description_email' => 'Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Digite o seu e-mail :email para confirmar.', + 'modal_description_password' => 'Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Se você for o dono da conta, os membros convidados também serão excluídos permanentemente. Digite sua senha para confirmar.', + 'modal_description_email' => 'Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Se você for o dono da conta, os membros convidados também serão excluídos permanentemente. Digite o seu e-mail :email para confirmar.', 'password' => 'Senha', 'password_placeholder' => 'Senha', 'email_placeholder' => 'Seu e-mail', @@ -140,6 +140,20 @@ 'name' => 'Nome', 'name_placeholder' => 'Meu Workspace', 'save' => 'Salvar', + 'danger_description' => 'Ações irreversíveis.', + 'delete_warning' => 'Atenção', + 'delete_title' => 'Excluir este workspace', + 'delete_description' => 'Exclui este workspace de forma permanente, incluindo posts, contas conectadas e mídia. Você passa a ser cobrado por um workspace a menos.', + 'delete_description_self_hosted' => 'Exclui permanentemente este workspace, seus posts, contas conectadas e mídia.', + 'delete_only_description' => 'Você não pode excluir seu único workspace. Cancele a assinatura na cobrança para parar de pagar, ou exclua sua conta em Autenticação para cancelar a cobrança e remover tudo permanentemente.', + 'delete_go_to_billing' => 'Ir para cobrança', + 'delete_go_to_delete_account' => 'Excluir conta', + 'delete_members_warning' => '{1}:count outro membro perderá o acesso. Membros sem outro workspace no TryPost terão a conta excluída permanentemente.|[2,*]:count outros membros perderão o acesso. Membros sem outro workspace no TryPost terão a conta excluída permanentemente.', + 'delete_action' => 'Excluir workspace', + 'delete_cancel' => 'Cancelar', + 'delete_confirm_title' => 'Excluir workspace?', + 'delete_confirm_description' => 'Isso exclui permanentemente o workspace e todos os seus dados. Sua cobrança será atualizada para refletir um workspace a menos.', + 'delete_confirm_description_self_hosted' => 'Isso exclui permanentemente o workspace e todos os seus dados.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'Este convite é para um endereço de e-mail diferente.', 'already_member' => 'Você já é membro deste workspace.', 'invite_accepted' => 'Bem-vindo! Você agora é membro do workspace.', + 'invite_workspace_gone' => 'Este convite não é mais válido porque o workspace foi excluído.', 'invite_declined' => 'Convite recusado.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'Configurações atualizadas com sucesso!', 'photo_updated' => 'Foto atualizada com sucesso!', 'photo_deleted' => 'Foto removida com sucesso!', + 'delete_failed_billing' => 'Não foi possível cancelar sua assinatura no provedor de cobrança. Nada foi excluído. Tente novamente ou entre em contato com o suporte.', 'logo_updated' => 'Logo enviado com sucesso!', 'logo_deleted' => 'Logo removido com sucesso!', 'notifications_updated' => 'Preferências de notificações atualizadas!', diff --git a/lang/ru/auth.php b/lang/ru/auth.php index 7f4ae6a7d..b538e7918 100644 --- a/lang/ru/auth.php +++ b/lang/ru/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => 'Войдите или создайте аккаунт, чтобы принять приглашение.', 'log_in' => 'Войти', 'create_account' => 'Создать аккаунт', + 'expired_title' => 'Это приглашение больше недействительно', + 'expired_description' => 'Рабочее пространство для этого приглашения было удалено. Попросите владельца аккаунта прислать новое приглашение, если доступ всё ещё нужен.', + 'expired_action' => 'На главную', ], ]; diff --git a/lang/ru/settings.php b/lang/ru/settings.php index adfeeefdd..4e3f5f2b8 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -110,11 +110,11 @@ 'heading' => 'Удалить аккаунт', 'description' => 'Удалите аккаунт и все его ресурсы', 'warning' => 'Внимание', - 'warning_message' => 'Действуйте осторожно, это действие нельзя отменить.', + 'warning_message' => 'Действуйте осторожно: это нельзя отменить. Если вы владелец этого аккаунта, приглашённые участники также будут удалены навсегда.', 'button' => 'Удалить аккаунт', 'modal_title' => 'Вы уверены, что хотите удалить аккаунт?', - 'modal_description_password' => 'После удаления аккаунта все его ресурсы и данные также будут безвозвратно удалены. Введите пароль для подтверждения.', - 'modal_description_email' => 'После удаления аккаунта все его ресурсы и данные также будут безвозвратно удалены. Введите свой адрес email :email для подтверждения.', + 'modal_description_password' => 'После удаления аккаунта все его ресурсы и данные также будут безвозвратно удалены. Если вы владелец аккаунта, приглашённые участники также будут удалены навсегда. Введите пароль для подтверждения.', + 'modal_description_email' => 'После удаления аккаунта все его ресурсы и данные также будут безвозвратно удалены. Если вы владелец аккаунта, приглашённые участники также будут удалены навсегда. Введите свой адрес email :email для подтверждения.', 'password' => 'Пароль', 'password_placeholder' => 'Пароль', 'email_placeholder' => 'Email вашего аккаунта', @@ -140,6 +140,20 @@ 'name' => 'Название', 'name_placeholder' => 'Моё рабочее пространство', 'save' => 'Сохранить', + 'danger_description' => 'Необратимые действия.', + 'delete_warning' => 'Внимание', + 'delete_title' => 'Удалить этот workspace', + 'delete_description' => 'Безвозвратно удаляет этот workspace и всё в нём — посты, подключённые аккаунты и медиа. Вам будут выставлять счёт на один workspace меньше.', + 'delete_description_self_hosted' => 'Безвозвратно удаляет этот workspace, его посты, подключённые аккаунты и медиа.', + 'delete_only_description' => 'Вы не можете удалить свой единственный workspace. Отмените подписку в разделе оплаты, чтобы перестать платить, или удалите аккаунт в Аутентификации, чтобы отменить оплату и навсегда удалить всё.', + 'delete_go_to_billing' => 'Перейти к оплате', + 'delete_go_to_delete_account' => 'Удалить аккаунт', + 'delete_members_warning' => '{1}:count другой участник потеряет доступ. Участники без другого workspace в TryPost будут удалены навсегда.|[2,*]:count других участников потеряют доступ. Участники без другого workspace в TryPost будут удалены навсегда.', + 'delete_action' => 'Удалить workspace', + 'delete_cancel' => 'Отмена', + 'delete_confirm_title' => 'Удалить workspace?', + 'delete_confirm_description' => 'Это безвозвратно удалит workspace и все его данные. Оплата обновится с учётом на один workspace меньше.', + 'delete_confirm_description_self_hosted' => 'Это безвозвратно удалит workspace и все его данные.', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => 'Это приглашение для другого адреса email.', 'already_member' => 'Вы уже являетесь участником этого рабочего пространства.', 'invite_accepted' => 'Добро пожаловать! Теперь вы участник рабочего пространства.', + 'invite_workspace_gone' => 'Это приглашение больше недействительно, потому что рабочее пространство было удалено.', 'invite_declined' => 'Приглашение отклонено.', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => 'Настройки успешно обновлены!', 'photo_updated' => 'Фото успешно обновлено!', 'photo_deleted' => 'Фото успешно удалено!', + 'delete_failed_billing' => 'Не удалось отменить подписку у платёжного провайдера. Ничего не было удалено. Попробуйте снова или обратитесь в поддержку.', 'logo_updated' => 'Логотип успешно загружен!', 'logo_deleted' => 'Логотип успешно удалён!', 'notifications_updated' => 'Настройки уведомлений обновлены!', diff --git a/lang/tr/auth.php b/lang/tr/auth.php index 2beb75eb1..6e6633e81 100644 --- a/lang/tr/auth.php +++ b/lang/tr/auth.php @@ -138,6 +138,9 @@ 'login_prompt' => 'Bu daveti kabul etmek için giriş yapın veya bir hesap oluşturun.', 'log_in' => 'Giriş yap', 'create_account' => 'Hesap Oluştur', + 'expired_title' => 'Bu davet artık geçerli değil', + 'expired_description' => 'Bu davetin çalışma alanı silindi. Hâlâ erişime ihtiyacınız varsa hesap sahibinden yeni bir davet isteyin.', + 'expired_action' => 'Ana sayfaya git', ], ]; diff --git a/lang/tr/settings.php b/lang/tr/settings.php index 148bb834b..2e5fff939 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -112,11 +112,11 @@ 'heading' => 'Hesabı sil', 'description' => 'Hesabınızı ve tüm kaynaklarını silin', 'warning' => 'Uyarı', - 'warning_message' => 'Lütfen dikkatli ilerleyin, bu işlem geri alınamaz.', + 'warning_message' => 'Dikkatli olun: bu işlem geri alınamaz. Bu hesabın sahibiyseniz, davet edilen üyeler de kalıcı olarak silinir.', 'button' => 'Hesabı sil', 'modal_title' => 'Hesabınızı silmek istediğinizden emin misiniz?', - 'modal_description_password' => 'Hesabınız silindiğinde, tüm kaynakları ve verileri de kalıcı olarak silinecek. Onaylamak için lütfen parolanızı girin.', - 'modal_description_email' => 'Hesabınız silindiğinde, tüm kaynakları ve verileri de kalıcı olarak silinecek. Onaylamak için lütfen e-posta adresinizi :email yazın.', + 'modal_description_password' => 'Hesabınız silindiğinde, tüm kaynakları ve verileri de kalıcı olarak silinecek. Bu hesabın sahibiyseniz, davet edilen üyeler de kalıcı olarak silinir. Onaylamak için lütfen parolanızı girin.', + 'modal_description_email' => 'Hesabınız silindiğinde, tüm kaynakları ve verileri de kalıcı olarak silinecek. Bu hesabın sahibiyseniz, davet edilen üyeler de kalıcı olarak silinir. Onaylamak için lütfen e-posta adresinizi :email yazın.', 'password' => 'Parola', 'password_placeholder' => 'Parola', 'email_placeholder' => 'Hesap e-postanız', @@ -142,6 +142,20 @@ 'name' => 'Ad', 'name_placeholder' => 'Çalışma Alanım', 'save' => 'Kaydet', + 'danger_description' => 'Geri alınamaz işlemler.', + 'delete_warning' => 'Uyarı', + 'delete_title' => 'Bu workspace’i sil', + 'delete_description' => 'Bu workspace’i kalıcı olarak siler — gönderiler, bağlı hesaplar ve medya dahil. Bir workspace daha az ücretlendirilirsiniz.', + 'delete_description_self_hosted' => 'Bu workspace’i, gönderilerini, bağlı hesaplarını ve medyasını kalıcı olarak siler.', + 'delete_only_description' => 'Tek workspace’inizi silemezsiniz. Ödemeyi durdurmak için faturalandırmadan aboneliği iptal edin veya her şeyi kalıcı olarak silmek için Kimlik doğrulama ayarlarından hesabınızı silin.', + 'delete_go_to_billing' => 'Faturalandırmaya git', + 'delete_go_to_delete_account' => 'Hesabı sil', + 'delete_members_warning' => '{1}:count diğer üye erişimi kaybedecek. Başka bir TryPost workspace’i olmayan üyeler kalıcı olarak silinir.|[2,*]:count diğer üye erişimi kaybedecek. Başka bir TryPost workspace’i olmayan üyeler kalıcı olarak silinir.', + 'delete_action' => 'Workspace’i sil', + 'delete_cancel' => 'İptal', + 'delete_confirm_title' => 'Workspace silinsin mi?', + 'delete_confirm_description' => 'Bu, workspace’i ve tüm verilerini kalıcı olarak siler. Faturalandırma bir workspace eksilecek şekilde güncellenir.', + 'delete_confirm_description_self_hosted' => 'Bu, workspace’i ve tüm verilerini kalıcı olarak siler.', ], 'brand' => [ @@ -285,6 +299,7 @@ 'wrong_email' => 'Bu davet farklı bir e-posta adresi için.', 'already_member' => 'Zaten bu çalışma alanının bir üyesisiniz.', 'invite_accepted' => 'Hoş geldiniz! Artık çalışma alanının bir üyesisiniz.', + 'invite_workspace_gone' => 'Çalışma alanı silindiği için bu davet artık geçerli değil.', 'invite_declined' => 'Davet reddedildi.', ], ], @@ -312,6 +327,7 @@ 'workspace_updated' => 'Ayarlar başarıyla güncellendi!', 'photo_updated' => 'Fotoğraf başarıyla güncellendi!', 'photo_deleted' => 'Fotoğraf başarıyla kaldırıldı!', + 'delete_failed_billing' => 'Aboneliğinizi faturalama sağlayıcısında iptal edemedik. Hiçbir şey silinmedi. Lütfen tekrar deneyin veya destek ile iletişime geçin.', 'logo_updated' => 'Logo başarıyla yüklendi!', 'logo_deleted' => 'Logo başarıyla kaldırıldı!', 'notifications_updated' => 'Bildirim tercihleri güncellendi!', diff --git a/lang/zh/auth.php b/lang/zh/auth.php index 56a8a1c93..060399e66 100644 --- a/lang/zh/auth.php +++ b/lang/zh/auth.php @@ -136,6 +136,9 @@ 'login_prompt' => '登录或创建账户以接受此邀请。', 'log_in' => '登录', 'create_account' => '创建账户', + 'expired_title' => '此邀请已失效', + 'expired_description' => '该邀请对应的工作区已被删除。如仍需访问,请向账户所有者索取新邀请。', + 'expired_action' => '返回首页', ], ]; diff --git a/lang/zh/settings.php b/lang/zh/settings.php index eff7e82ca..1efe0eaa9 100644 --- a/lang/zh/settings.php +++ b/lang/zh/settings.php @@ -110,11 +110,11 @@ 'heading' => '删除账户', 'description' => '删除你的账户及其所有资源', 'warning' => '警告', - 'warning_message' => '请谨慎操作,此操作无法撤销。', + 'warning_message' => '请谨慎操作,此操作无法撤销。如果你是此账户的所有者,受邀成员也将被永久删除。', 'button' => '删除账户', 'modal_title' => '确定要删除你的账户吗?', - 'modal_description_password' => '账户一经删除,其所有资源和数据也将被永久删除。请输入你的密码以确认。', - 'modal_description_email' => '账户一经删除,其所有资源和数据也将被永久删除。请输入你的邮箱地址 :email 以确认。', + 'modal_description_password' => '账户一经删除,其所有资源和数据也将被永久删除。如果你是此账户的所有者,受邀成员也将被永久删除。请输入你的密码以确认。', + 'modal_description_email' => '账户一经删除,其所有资源和数据也将被永久删除。如果你是此账户的所有者,受邀成员也将被永久删除。请输入你的邮箱地址 :email 以确认。', 'password' => '密码', 'password_placeholder' => '密码', 'email_placeholder' => '你的账户邮箱', @@ -140,6 +140,20 @@ 'name' => '名称', 'name_placeholder' => '我的工作区', 'save' => '保存', + 'danger_description' => '不可撤销的操作。', + 'delete_warning' => '警告', + 'delete_title' => '删除此工作区', + 'delete_description' => '永久删除此工作区及其全部内容(帖子、已连接账户和媒体)。之后将按少一个工作区计费。', + 'delete_description_self_hosted' => '永久删除此工作区及其帖子、已连接账户和媒体。', + 'delete_only_description' => '无法删除唯一的工作区。可在账单中取消订阅以停止付费,或在身份验证设置中删除账户以取消计费并永久删除全部数据。', + 'delete_go_to_billing' => '前往账单', + 'delete_go_to_delete_account' => '删除账户', + 'delete_members_warning' => '{1}另有 :count 位成员将失去访问权限。没有其他 TryPost 工作区的成员账户将被永久删除。|[2,*]另有 :count 位成员将失去访问权限。没有其他 TryPost 工作区的成员账户将被永久删除。', + 'delete_action' => '删除工作区', + 'delete_cancel' => '取消', + 'delete_confirm_title' => '删除工作区?', + 'delete_confirm_description' => '这将永久删除工作区及其所有数据。账单将更新为少一个工作区。', + 'delete_confirm_description_self_hosted' => '这将永久删除工作区及其所有数据。', ], 'brand' => [ @@ -283,6 +297,7 @@ 'wrong_email' => '此邀请是发给另一个邮箱地址的。', 'already_member' => '你已经是此工作区的成员。', 'invite_accepted' => '欢迎!你现在是此工作区的成员了。', + 'invite_workspace_gone' => '该工作区已删除,此邀请已失效。', 'invite_declined' => '邀请已拒绝。', ], ], @@ -310,6 +325,7 @@ 'workspace_updated' => '设置更新成功!', 'photo_updated' => '头像更新成功!', 'photo_deleted' => '头像移除成功!', + 'delete_failed_billing' => '无法在账单提供商处取消订阅。未删除任何内容。请重试或联系支持。', 'logo_updated' => '徽标上传成功!', 'logo_deleted' => '徽标移除成功!', 'notifications_updated' => '通知偏好已更新!', diff --git a/resources/js/components/settings/DeleteWorkspace.vue b/resources/js/components/settings/DeleteWorkspace.vue new file mode 100644 index 000000000..e405aff42 --- /dev/null +++ b/resources/js/components/settings/DeleteWorkspace.vue @@ -0,0 +1,122 @@ + + + diff --git a/resources/js/components/settings/WorkspaceTab.vue b/resources/js/components/settings/WorkspaceTab.vue index cad7a69c7..100fdc5c1 100644 --- a/resources/js/components/settings/WorkspaceTab.vue +++ b/resources/js/components/settings/WorkspaceTab.vue @@ -2,6 +2,7 @@ import { Form } from '@inertiajs/vue3'; import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController'; +import DeleteWorkspace from '@/components/settings/DeleteWorkspace.vue'; import HeadingSmall from '@/components/HeadingSmall.vue'; import InputError from '@/components/InputError.vue'; import PhotoUpload from '@/components/PhotoUpload.vue'; @@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; +import { useWorkspaceRole } from '@/composables/useWorkspaceRole'; import { uploadLogo, deleteLogo } from '@/routes/app/workspace'; interface Workspace { @@ -20,7 +22,11 @@ interface Workspace { defineProps<{ workspace: Workspace; + isOnlyWorkspace: boolean; + otherMemberCount: number; }>(); + +const { canManageBilling } = useWorkspaceRole(); diff --git a/resources/js/pages/auth/AcceptInvite.vue b/resources/js/pages/auth/AcceptInvite.vue index ff3f5cfbc..281c65186 100644 --- a/resources/js/pages/auth/AcceptInvite.vue +++ b/resources/js/pages/auth/AcceptInvite.vue @@ -1,14 +1,17 @@ \ No newline at end of file + diff --git a/resources/js/pages/settings/workspace/Workspace.vue b/resources/js/pages/settings/workspace/Workspace.vue index f525273df..56db8152d 100644 --- a/resources/js/pages/settings/workspace/Workspace.vue +++ b/resources/js/pages/settings/workspace/Workspace.vue @@ -24,6 +24,8 @@ interface Workspace { defineProps<{ workspace: Workspace; + isOnlyWorkspace: boolean; + otherMemberCount: number; }>(); const tabs = computed(() => [ @@ -46,7 +48,11 @@ const tabs = computed(() => [ - + diff --git a/resources/js/pages/workspaces/Index.vue b/resources/js/pages/workspaces/Index.vue index 38a2b45ad..f87653cc1 100644 --- a/resources/js/pages/workspaces/Index.vue +++ b/resources/js/pages/workspaces/Index.vue @@ -5,6 +5,7 @@ import { trans } from 'laravel-vue-i18n'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { useWorkspaceRole } from '@/composables/useWorkspaceRole'; import AuthLayout from '@/layouts/AuthLayout.vue'; import { create as createWorkspace, switchMethod } from '@/routes/app/workspaces'; @@ -23,6 +24,8 @@ interface Props { defineProps(); +const { canCreateWorkspace } = useWorkspaceRole(); + const switchToWorkspace = (workspace: Workspace) => { router.post(switchMethod.url(workspace.id), {}, { preserveState: false, @@ -63,7 +66,7 @@ const switchToWorkspace = (workspace: Workspace) => { - + diff --git a/tests/Feature/AcceptInviteControllerTest.php b/tests/Feature/AcceptInviteControllerTest.php index d1cb85aa2..820ae79af 100644 --- a/tests/Feature/AcceptInviteControllerTest.php +++ b/tests/Feature/AcceptInviteControllerTest.php @@ -35,6 +35,7 @@ $response->assertOk(); $response->assertInertia(fn ($page) => $page ->component('auth/AcceptInvite', false) + ->where('expired', false) ->has('invite') ->where('invite.id', $invite->id) ->where('invite.email', 'newuser@example.com') @@ -57,6 +58,7 @@ $response->assertOk(); $response->assertInertia(fn ($page) => $page ->component('auth/AcceptInvite', false) + ->where('expired', false) ->has('invite') ->where('invite.id', $invite->id) ); @@ -79,10 +81,34 @@ $response->assertOk(); $response->assertInertia(fn ($page) => $page ->component('auth/AcceptInvite', false) + ->where('expired', false) ->has('invite') ); }); +test('show invite marks expired when workspace is gone without mutating on GET', function () { + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'newuser@example.com', + 'workspaces' => [$this->workspace->id], + ]); + + $this->workspace->delete(); + + $response = $this->get(route('app.invites.show', $invite)); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('auth/AcceptInvite', false) + ->where('expired', true) + ->where('invite', null) + ); + + // Prefetch-safe: cleanup is deferred to accept/decline / workspace delete. + expect(Invite::find($invite->id))->not->toBeNull(); +}); + test('show invite returns 404 for non-existent invite', function () { $response = $this->get(route('app.invites.show', 'non-existent-uuid')); @@ -104,6 +130,7 @@ $user = User::factory()->create([ 'email' => 'invitee@example.com', ]); + $personalAccountId = $user->account_id; $invite = Invite::factory()->create([ 'account_id' => $this->account->id, @@ -119,6 +146,7 @@ // User should be added to the account $user->refresh(); expect($user->account_id)->toBe($this->account->id); + expect(Account::find($personalAccountId))->toBeNull(); // User should be member of workspace expect($this->workspace->members()->where('user_id', $user->id)->exists())->toBeTrue(); @@ -131,6 +159,60 @@ expect($invite->accepted_at)->not->toBeNull(); }); +test('accept invite keeps a personal account that still has a workspace', function () { + $user = User::factory()->create([ + 'email' => 'invitee@example.com', + ]); + $personalAccountId = $user->account_id; + $personalWorkspace = Workspace::factory()->create([ + 'account_id' => $personalAccountId, + 'user_id' => $user->id, + ]); + $personalWorkspace->members()->attach($user->id, ['role' => Role::Admin->value]); + + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'invitee@example.com', + 'workspaces' => [$this->workspace->id], + ]); + + $this->actingAs($user)->post(route('app.invites.accept', $invite)); + + $user->refresh(); + expect($user->account_id)->toBe($this->account->id); + expect(Account::find($personalAccountId))->not->toBeNull(); + expect(Workspace::find($personalWorkspace->id))->not->toBeNull(); +}); + +test('accept invite switches current workspace off a personal workspace when joining another account', function () { + $user = User::factory()->create([ + 'email' => 'invitee@example.com', + ]); + $personalWorkspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $personalWorkspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $personalWorkspace->id]); + + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'invitee@example.com', + 'workspaces' => [$this->workspace->id], + ]); + + $response = $this->actingAs($user)->post(route('app.invites.accept', $invite)); + + $response->assertRedirect(route('app.calendar')); + + $user->refresh(); + expect($user->account_id)->toBe($this->account->id); + expect($user->current_workspace_id)->toBe($this->workspace->id); + expect($this->workspace->members()->where('user_id', $user->id)->exists())->toBeTrue(); +}); + test('accept invite assigns the exact role from the invite', function (Role $role) { $user = User::factory()->create([ 'email' => 'invitee@example.com', @@ -172,7 +254,7 @@ $response = $this->actingAs($user)->post(route('app.invites.accept', $invite)); - $response->assertRedirect(route('app.calendar')); + $response->assertRedirect(route('app.workspaces.create')); $response->assertSessionHas('flash.bannerStyle', 'danger'); // Invite should NOT be accepted @@ -191,6 +273,7 @@ 'invited_by' => $this->owner->id, 'email' => 'invitee@example.com', 'workspaces' => [$this->workspace->id], + 'role' => Role::Member, ]); $response = $this->actingAs($user)->post(route('app.invites.accept', $invite)); @@ -201,6 +284,169 @@ // Invite should be marked as accepted $invite->refresh(); expect($invite->accepted_at)->not->toBeNull(); + + // Still attach missing workspace memberships for users already on the account. + expect($this->workspace->members()->where('user_id', $user->id)->exists())->toBeTrue(); + expect($user->fresh()->current_workspace_id)->toBe($this->workspace->id); +}); + +test('accept invite rejects invites whose workspaces were deleted', function () { + $user = User::factory()->create([ + 'email' => 'invitee@example.com', + ]); + $personalAccountId = $user->account_id; + + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'invitee@example.com', + 'workspaces' => [$this->workspace->id], + ]); + + $this->workspace->delete(); + + $response = $this->actingAs($user)->post(route('app.invites.accept', $invite)); + + // No current workspace → create (keeps flash; avoids EnsureHasWorkspace bounce). + $response->assertRedirect(route('app.workspaces.create')); + $response->assertSessionHas('flash.banner', __('settings.members.flash.invite_workspace_gone')); + $response->assertSessionHas('flash.bannerStyle', 'danger'); + + expect(Invite::find($invite->id))->toBeNull(); + expect($user->fresh()->account_id)->toBe($personalAccountId); + expect($user->fresh()->isAccountOwner())->toBeTrue(); +}); + +test('accept invite does not demote an existing workspace admin', function () { + $user = User::factory()->create([ + 'email' => 'invitee@example.com', + 'account_id' => $this->account->id, + ]); + $this->workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'invitee@example.com', + 'workspaces' => [$this->workspace->id], + 'role' => Role::Viewer, + ]); + + $this->actingAs($user)->post(route('app.invites.accept', $invite)); + + $member = $this->workspace->members()->where('user_id', $user->id)->first(); + + expect($member->pivot->role)->toBe(Role::Admin->value); + expect($invite->fresh()->accepted_at)->not->toBeNull(); +}); + +test('accepting an already accepted invite does not claim the workspace was deleted', function () { + $user = User::factory()->create([ + 'email' => 'invitee@example.com', + 'account_id' => $this->account->id, + 'current_workspace_id' => $this->workspace->id, + ]); + $this->workspace->members()->attach($user->id, ['role' => Role::Member->value]); + + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'invitee@example.com', + 'workspaces' => [$this->workspace->id], + 'accepted_at' => now(), + ]); + + $response = $this->actingAs($user)->post(route('app.invites.accept', $invite)); + + $response->assertRedirect(route('app.calendar')); + $response->assertSessionHas('flash.banner', __('settings.members.flash.already_member')); + $response->assertSessionHas('flash.bannerStyle', 'info'); +}); + +test('invite redirect deletes a stranded non-owner with no personal workspace', function () { + [ + 'member' => $member, + 'personal_account_id' => $personalAccountId, + ] = strandedMemberOnSharedAccount( + owner: $this->owner, + memberEmail: 'invitee@example.com', + ); + + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'invitee@example.com', + 'workspaces' => [$this->workspace->id], + ]); + + $this->workspace->delete(); + + $response = $this->actingAs($member)->post(route('app.invites.accept', $invite)); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('flash.banner', __('settings.members.flash.invite_workspace_gone')); + + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); +}); + +test('decline of a dead invite deletes a stranded non-owner', function () { + [ + 'member' => $member, + 'personal_account_id' => $personalAccountId, + ] = strandedMemberOnSharedAccount( + owner: $this->owner, + memberEmail: 'invitee@example.com', + ); + + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'invitee@example.com', + 'workspaces' => [$this->workspace->id], + ]); + + $this->workspace->delete(); + + $response = $this->actingAs($member)->post(route('app.invites.decline', $invite)); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('flash.banner', __('settings.members.flash.invite_workspace_gone')); + + expect(Invite::find($invite->id))->toBeNull(); + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); +}); + +test('invite redirect restores stranded non-owner with a personal workspace instead of cross-account current', function () { + [ + 'member' => $member, + 'personal_account_id' => $personalAccountId, + 'personal_workspace' => $personalWorkspace, + ] = strandedMemberOnSharedAccount( + withPersonalWorkspace: true, + owner: $this->owner, + memberEmail: 'invitee@example.com', + ); + + $invite = Invite::factory()->create([ + 'account_id' => $this->account->id, + 'invited_by' => $this->owner->id, + 'email' => 'invitee@example.com', + 'workspaces' => [$this->workspace->id], + ]); + + $this->workspace->delete(); + + $response = $this->actingAs($member)->post(route('app.invites.accept', $invite)); + + $response->assertRedirect(route('app.calendar')); + $response->assertSessionHas('flash.banner', __('settings.members.flash.invite_workspace_gone')); + + $member->refresh(); + expect($member->account_id)->toBe($personalAccountId); + expect($member->isAccountOwner())->toBeTrue(); + expect($member->current_workspace_id)->toBe($personalWorkspace->id); }); test('decline invite requires authentication', function () { @@ -228,7 +474,7 @@ $response = $this->actingAs($user)->post(route('app.invites.decline', $invite)); - $response->assertRedirect(route('app.calendar')); + $response->assertRedirect(route('app.workspaces.create')); $response->assertSessionHas('flash.bannerStyle', 'info'); // Invite should be deleted @@ -249,7 +495,7 @@ $response = $this->actingAs($user)->post(route('app.invites.decline', $invite)); - $response->assertRedirect(route('app.calendar')); + $response->assertRedirect(route('app.workspaces.create')); $response->assertSessionHas('flash.bannerStyle', 'danger'); // Invite should NOT be deleted diff --git a/tests/Feature/Actions/Invite/RemoveMemberTest.php b/tests/Feature/Actions/Invite/RemoveMemberTest.php new file mode 100644 index 000000000..3ed6fdce1 --- /dev/null +++ b/tests/Feature/Actions/Invite/RemoveMemberTest.php @@ -0,0 +1,83 @@ + $owner, + 'member' => $member, + 'shared_workspaces' => [$workspace, $other], + ] = strandedMemberOnSharedAccount( + sharedWorkspaces: 2, + setMemberCurrent: true, + ); + + RemoveMember::execute($workspace, $member->id); + + $member->refresh(); + + expect($workspace->members()->where('user_id', $member->id)->exists())->toBeFalse(); + expect($member->current_workspace_id)->toBe($other->id); + expect($member->account_id)->toBe($owner->account_id); +}); + +test('remove member deletes a user who loses their last account workspace', function () { + [ + 'member' => $member, + 'personal_account_id' => $personalAccountId, + 'shared_workspaces' => [$workspace], + ] = strandedMemberOnSharedAccount( + sharedWorkspaces: 1, + setMemberCurrent: true, + ); + + RemoveMember::execute($workspace, $member->id); + + expect($workspace->members()->where('user_id', $member->id)->exists())->toBeFalse(); + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); +}); + +test('remove member prefers a same-account workspace over a personal membership', function () { + [ + 'owner' => $owner, + 'member' => $member, + 'shared_workspaces' => [$sharedA, $sharedB], + ] = strandedMemberOnSharedAccount( + withPersonalWorkspace: true, + sharedWorkspaces: 2, + setMemberCurrent: true, + ); + + RemoveMember::execute($sharedA, $member->id); + + $member->refresh(); + + expect($member->account_id)->toBe($owner->account_id); + expect($member->current_workspace_id)->toBe($sharedB->id); +}); + +test('remove member restores personal workspace instead of keeping a cross-account current', function () { + [ + 'member' => $member, + 'personal_account_id' => $personalAccountId, + 'personal_workspace' => $personalWorkspace, + 'shared_workspaces' => [$shared], + ] = strandedMemberOnSharedAccount( + withPersonalWorkspace: true, + sharedWorkspaces: 1, + setMemberCurrent: true, + ); + + RemoveMember::execute($shared, $member->id); + + $member->refresh(); + + expect($member->account_id)->toBe($personalAccountId); + expect($member->isAccountOwner())->toBeTrue(); + expect($member->current_workspace_id)->toBe($personalWorkspace->id); +}); diff --git a/tests/Feature/Actions/Media/DeleteWorkspaceMediaTest.php b/tests/Feature/Actions/Media/DeleteWorkspaceMediaTest.php new file mode 100644 index 000000000..d43168c6a --- /dev/null +++ b/tests/Feature/Actions/Media/DeleteWorkspaceMediaTest.php @@ -0,0 +1,78 @@ +create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + + $media = $workspace->addMedia( + UploadedFile::fake()->image('logo.jpg'), + 'logo', + ); + $path = $media->path; + Storage::assertExists($path); + + $paths = DeleteWorkspaceMedia::purgeRecords($workspace); + + expect($paths)->toBe([$path]); + expect(Media::find($media->id))->toBeNull(); + Storage::assertExists($path); + + DeleteOrphanedMediaFiles::execute($paths); + + Storage::assertMissing($path); +}); + +test('purgeRecords does not delete files still referenced by other media', function () { + Storage::fake(); + + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $other = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + + $media = $workspace->addMedia( + UploadedFile::fake()->image('shared.jpg'), + 'logo', + ); + $path = $media->path; + + Media::query()->create([ + 'mediable_type' => $other->getMorphClass(), + 'mediable_id' => $other->id, + 'collection' => 'logo', + 'type' => $media->type, + 'path' => $path, + 'original_filename' => $media->original_filename, + 'mime_type' => $media->mime_type, + 'size' => $media->size, + 'order' => 0, + 'meta' => [], + ]); + + $paths = DeleteWorkspaceMedia::purgeRecords($workspace); + + DeleteOrphanedMediaFiles::execute($paths); + + expect(Media::query()->where('path', $path)->count())->toBe(1); + Storage::assertExists($path); +}); diff --git a/tests/Feature/Actions/Workspace/DeleteWorkspaceLazyLoadTest.php b/tests/Feature/Actions/Workspace/DeleteWorkspaceLazyLoadTest.php new file mode 100644 index 000000000..0475f9ea7 --- /dev/null +++ b/tests/Feature/Actions/Workspace/DeleteWorkspaceLazyLoadTest.php @@ -0,0 +1,38 @@ +create(); + + $current = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $current->members()->attach($owner->id, ['role' => Role::Admin->value]); + $owner->update(['current_workspace_id' => $current->id]); + + $other = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + + $member = User::factory()->create(['account_id' => $owner->account_id]); + $current->members()->attach($member->id, ['role' => Role::Member->value]); + $member->update(['current_workspace_id' => $current->id]); + + expect(DeleteWorkspace::execute($current))->toBeTrue(); + + $owner->refresh(); + + expect($owner->current_workspace_id)->toBe($other->id); + expect(User::find($member->id))->toBeNull(); +}); diff --git a/tests/Feature/Actions/Workspace/DeleteWorkspaceMemberCleanupTest.php b/tests/Feature/Actions/Workspace/DeleteWorkspaceMemberCleanupTest.php new file mode 100644 index 000000000..408b4d994 --- /dev/null +++ b/tests/Feature/Actions/Workspace/DeleteWorkspaceMemberCleanupTest.php @@ -0,0 +1,182 @@ + $member, + 'personal_account_id' => $personalAccountId, + 'shared_workspaces' => [$workspace], + ] = strandedMemberOnSharedAccount( + sharedWorkspaces: 2, + attachMemberToAll: false, + setMemberCurrent: true, + ); + + DeleteWorkspace::execute($workspace); + + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); +}); + +test('delete workspace does not delete members who still have another workspace on the account', function () { + [ + 'owner' => $owner, + 'member' => $member, + 'shared_workspaces' => [$first, $second], + ] = strandedMemberOnSharedAccount( + sharedWorkspaces: 2, + setMemberCurrent: true, + ); + + DeleteWorkspace::execute($first); + + $member->refresh(); + + expect($member->account_id)->toBe($owner->account_id); + expect($member->current_workspace_id)->toBe($second->id); +}); + +test('delete workspace restores members who still own a personal workspace', function () { + [ + 'member' => $member, + 'personal_account_id' => $personalAccountId, + 'personal_workspace' => $personalWorkspace, + 'shared_workspaces' => [$sharedWorkspace], + ] = strandedMemberOnSharedAccount( + withPersonalWorkspace: true, + sharedWorkspaces: 2, + attachMemberToAll: false, + setMemberCurrent: true, + ); + + DeleteWorkspace::execute($sharedWorkspace); + + $member->refresh(); + + expect($member->account_id)->toBe($personalAccountId); + expect($member->current_workspace_id)->toBe($personalWorkspace->id); + expect($member->isAccountOwner())->toBeTrue(); +}); + +test('delete workspace falls back to an account workspace the owner is not pivoted into', function () { + $owner = User::factory()->create(); + + $current = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $current->members()->attach($owner->id, ['role' => Role::Admin->value]); + $owner->update(['current_workspace_id' => $current->id]); + + $memberCreated = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + // Owner can access via account ownership but is not on the pivot. + + expect(DeleteWorkspace::execute($current))->toBeTrue(); + + $owner->refresh(); + + expect($owner->current_workspace_id)->toBe($memberCreated->id); + expect($owner->belongsToWorkspace($memberCreated))->toBeTrue(); +}); + +test('delete workspace removes pending invites that only target that workspace', function () { + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $workspace->members()->attach($owner->id, ['role' => Role::Admin->value]); + + $invite = Invite::factory()->create([ + 'account_id' => $owner->account_id, + 'invited_by' => $owner->id, + 'workspaces' => [$workspace->id], + ]); + + expect(DeleteWorkspace::execute($workspace))->toBeTrue(); + + expect(Invite::find($invite->id))->toBeNull(); +}); + +test('delete workspace prunes the deleted workspace id from multi-workspace invites', function () { + $owner = User::factory()->create(); + $first = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $second = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $first->members()->attach($owner->id, ['role' => Role::Admin->value]); + + $invite = Invite::factory()->create([ + 'account_id' => $owner->account_id, + 'invited_by' => $owner->id, + 'workspaces' => [$first->id, $second->id], + ]); + + expect(DeleteWorkspace::execute($first))->toBeTrue(); + + expect($invite->fresh()->workspaces)->toBe([$second->id]); +}); + +test('delete workspace deletes workspace media files and rows', function () { + Storage::fake(); + + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $workspace->members()->attach($owner->id, ['role' => Role::Admin->value]); + + $media = $workspace->addMedia( + UploadedFile::fake()->image('logo.jpg'), + 'logo', + ); + $mediaPath = $media->path; + Storage::assertExists($mediaPath); + + expect(DeleteWorkspace::execute($workspace))->toBeTrue(); + + expect(Media::find($media->id))->toBeNull(); + expect(Workspace::find($workspace->id))->toBeNull(); + Storage::assertMissing($mediaPath); +}); + +test('delete workspace returns false when saas blocks the last workspace', function () { + config(['trypost.self_hosted' => false]); + + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $workspace->members()->attach($owner->id, ['role' => Role::Admin->value]); + + expect(DeleteWorkspace::execute($workspace))->toBeFalse(); + expect(Workspace::find($workspace->id))->not->toBeNull(); +}); diff --git a/tests/Feature/Actions/Workspace/DeleteWorkspaceTest.php b/tests/Feature/Actions/Workspace/DeleteWorkspaceTest.php index 92a2095da..dca55cf99 100644 --- a/tests/Feature/Actions/Workspace/DeleteWorkspaceTest.php +++ b/tests/Feature/Actions/Workspace/DeleteWorkspaceTest.php @@ -26,7 +26,7 @@ Bus::fake(); - DeleteWorkspace::execute($user, $workspace); + DeleteWorkspace::execute($workspace); expect(Workspace::find($workspace->id))->toBeNull(); @@ -52,7 +52,7 @@ Bus::fake(); - DeleteWorkspace::execute($user, $workspace); + DeleteWorkspace::execute($workspace); expect(Workspace::find($workspace->id))->toBeNull(); diff --git a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php index 8f2bae64e..4ea23940e 100644 --- a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php +++ b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php @@ -61,15 +61,17 @@ }); test('deleting a workspace syncs the stripe quantity', function () { + config()->set('trypost.self_hosted', true); + $user = User::factory()->create(); $workspace = Workspace::factory()->create([ 'account_id' => $user->account_id, 'user_id' => $user->id, ]); - $account = mock(Account::class)->makePartial(); + $account = Mockery::mock($user->account)->makePartial(); $account->shouldReceive('syncWorkspaceQuantity')->once(); $workspace->setRelation('account', $account); - DeleteWorkspace::execute($user, $workspace); + expect(DeleteWorkspace::execute($workspace))->toBeTrue(); }); diff --git a/tests/Feature/LocalizationParityTest.php b/tests/Feature/LocalizationParityTest.php index a6701f505..6bb133724 100644 --- a/tests/Feature/LocalizationParityTest.php +++ b/tests/Feature/LocalizationParityTest.php @@ -45,3 +45,7 @@ expect($missingFiles)->toBe([], "{$locale} is missing translation files: ".implode(', ', $missingFiles)); expect($keyDrift)->toBe([], "{$locale} has key drift: ".json_encode($keyDrift, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); })->with(ContentLanguage::values()); + +// Key presence alone cannot catch stale wording (same key, incomplete sentence). +// Destructive account/workspace delete copy is additionally asserted in +// tests/Unit/Settings/DeleteAccountCopyTest.php with per-locale content markers. diff --git a/tests/Feature/Permissions/WorkspaceRolePermissionsTest.php b/tests/Feature/Permissions/WorkspaceRolePermissionsTest.php index 9fdf73338..3e32306ad 100644 --- a/tests/Feature/Permissions/WorkspaceRolePermissionsTest.php +++ b/tests/Feature/Permissions/WorkspaceRolePermissionsTest.php @@ -134,3 +134,29 @@ ->post(route('app.posts.store')) ->assertRedirect(route('app.accounts')); }); + +test('a member cannot open the create workspace form', function () { + $this->actingAs($this->member) + ->get(route('app.workspaces.create')) + ->assertForbidden(); +}); + +test('a member cannot store a workspace on the shared account', function () { + $this->actingAs($this->member) + ->post(route('app.workspaces.store'), [ + 'name' => 'Sneaky Workspace', + ]) + ->assertForbidden(); + + expect(Workspace::where('name', 'Sneaky Workspace')->exists())->toBeFalse(); +}); + +test('a workspace admin cannot store a workspace on the shared account', function () { + $this->actingAs($this->admin) + ->post(route('app.workspaces.store'), [ + 'name' => 'Admin Workspace', + ]) + ->assertForbidden(); + + expect(Workspace::where('name', 'Admin Workspace')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 60786517a..5f3d2e4c0 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -3,11 +3,15 @@ declare(strict_types=1); use App\Enums\UserWorkspace\Role; +use App\Models\AccessToken; use App\Models\Account; +use App\Models\Invite; +use App\Models\Media; use App\Models\User; use App\Models\Workspace; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Laravel\Cashier\Subscription; test('profile page is displayed', function () { $user = User::factory()->create(); @@ -103,6 +107,18 @@ expect($user->fresh())->toBeNull(); }); +test('owner deleting profile revokes their passport tokens', function () { + $owner = User::factory()->create(); + $token = $owner->createToken('API Key')->token; + + $this->actingAs($owner)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + expect(User::find($owner->id))->toBeNull(); + expect(AccessToken::find($token->id)->revoked)->toBeTrue(); +}); + test('correct password must be provided to delete account', function () { $user = User::factory()->create(); @@ -120,64 +136,51 @@ expect($user->fresh())->not->toBeNull(); }); -test('deleting account updates members current_workspace_id when their workspace is deleted', function () { - $owner = User::factory()->create(); - $member = User::factory()->create(); - - // Create a workspace owned by the owner - $workspace = Workspace::factory()->create(['user_id' => $owner->id]); - $owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]); - $owner->update(['current_workspace_id' => $workspace->id]); - - // Add member to the workspace and set it as their current - $member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]); - $member->update(['current_workspace_id' => $workspace->id]); - - // Verify setup - expect($member->current_workspace_id)->toBe($workspace->id); +test('deleting account deletes members who belong to the shared account', function () { + [ + 'owner' => $owner, + 'member' => $member, + 'personal_account_id' => $personalAccountId, + 'shared_workspaces' => [$workspace], + ] = strandedMemberOnSharedAccount( + sharedWorkspaces: 1, + setMemberCurrent: true, + ); + $memberToken = $member->createToken('Member API')->token; - // Owner deletes their account $this ->actingAs($owner) ->delete(route('app.profile.destroy'), [ 'password' => 'password', ]); - // Verify member's current_workspace_id is updated to null (since they have no other workspace) - expect($member->fresh()->current_workspace_id)->toBeNull(); + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); + expect(AccessToken::find($memberToken->id)->revoked)->toBeTrue(); }); -test('deleting account updates members current_workspace_id to another workspace when available', function () { - $owner = User::factory()->create(); - $member = User::factory()->create(); - $otherOwner = User::factory()->create(); - - // Create workspace owned by the owner being deleted - $workspaceToDelete = Workspace::factory()->create(['user_id' => $owner->id]); - $owner->workspaces()->attach($workspaceToDelete->id, ['role' => Role::Member->value]); - $owner->update(['current_workspace_id' => $workspaceToDelete->id]); - - // Create another workspace owned by a different user - $otherWorkspace = Workspace::factory()->create(['user_id' => $otherOwner->id]); - $otherOwner->workspaces()->attach($otherWorkspace->id, ['role' => Role::Member->value]); - - // Add member to both workspaces - $member->workspaces()->attach($workspaceToDelete->id, ['role' => Role::Member->value]); - $member->workspaces()->attach($otherWorkspace->id, ['role' => Role::Member->value]); - $member->update(['current_workspace_id' => $workspaceToDelete->id]); - - // Verify setup - expect($member->current_workspace_id)->toBe($workspaceToDelete->id); +test('deleting account deletes member who still owns a personal workspace', function () { + [ + 'owner' => $owner, + 'member' => $member, + 'personal_account_id' => $personalAccountId, + 'personal_workspace' => $personalWorkspace, + 'shared_workspaces' => [$workspaceToDelete], + ] = strandedMemberOnSharedAccount( + withPersonalWorkspace: true, + sharedWorkspaces: 1, + setMemberCurrent: true, + ); - // Owner deletes their account $this ->actingAs($owner) ->delete(route('app.profile.destroy'), [ 'password' => 'password', ]); - // Verify member's current_workspace_id is updated to the other workspace - expect($member->fresh()->current_workspace_id)->toBe($otherWorkspace->id); + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); + expect(Workspace::find($personalWorkspace->id))->toBeNull(); }); test('user can upload profile photo', function () { @@ -275,6 +278,26 @@ expect($owner->fresh())->not->toBeNull(); })->with([true, false]); +test('member deleting profile does not delete shared workspaces they created', function () { + $owner = User::factory()->create(); + $member = User::factory()->create(['account_id' => $owner->account_id]); + + $sharedWorkspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $member->id, + ]); + $sharedWorkspace->members()->attach($owner->id, ['role' => Role::Admin->value]); + $sharedWorkspace->members()->attach($member->id, ['role' => Role::Member->value]); + + $this->actingAs($member)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + expect(User::find($member->id))->toBeNull(); + expect(Workspace::find($sharedWorkspace->id))->not->toBeNull(); + expect(Account::find($owner->account_id))->not->toBeNull(); +}); + test('member deleting profile detaches them from workspaces', function (bool $selfHosted) { config()->set('trypost.self_hosted', $selfHosted); @@ -294,6 +317,27 @@ expect($workspace->fresh()->members()->where('users.id', $member->id)->exists())->toBeFalse(); })->with([true, false]); +test('owner deleting profile deletes remaining members of the account', function () { + [ + 'owner' => $owner, + 'member' => $member, + 'personal_account_id' => $personalAccountId, + 'shared_workspaces' => [$workspace], + ] = strandedMemberOnSharedAccount( + sharedWorkspaces: 1, + setMemberCurrent: true, + ); + $accountId = $owner->account_id; + + $this->actingAs($owner)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + expect(Account::find($accountId))->toBeNull(); + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); +}); + test('owner deleting profile destroys the account and cascades', function (bool $selfHosted) { config()->set('trypost.self_hosted', $selfHosted); @@ -313,3 +357,163 @@ expect(Account::find($accountId))->toBeNull(); expect(Workspace::find($workspace->id))->toBeNull(); })->with([true, false]); + +test('owner deleting profile clears media for account workspaces not owned by user_id', function () { + Storage::fake(); + + $owner = User::factory()->create(); + $accountId = $owner->account_id; + + $memberCreated = Workspace::factory()->create([ + 'account_id' => $accountId, + 'user_id' => User::factory()->create(['account_id' => $accountId])->id, + ]); + $media = $memberCreated->addMedia( + UploadedFile::fake()->image('logo.jpg'), + 'logo', + ); + $mediaPath = $media->path; + Storage::assertExists($mediaPath); + + $this->actingAs($owner)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + expect(Account::find($accountId))->toBeNull(); + expect(Workspace::find($memberCreated->id))->toBeNull(); + expect(Media::find($media->id))->toBeNull(); + Storage::assertMissing($mediaPath); +}); + +test('owner deleting profile clears avatar media', function () { + Storage::fake(); + + $owner = User::factory()->create(); + $avatar = $owner->addMedia( + UploadedFile::fake()->image('avatar.jpg', 200, 200), + 'avatar', + ); + $avatarPath = $avatar->path; + Storage::assertExists($avatarPath); + + $this->actingAs($owner)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + expect($owner->fresh())->toBeNull(); + expect(Media::find($avatar->id))->toBeNull(); + Storage::assertMissing($avatarPath); +}); + +test('owner account delete aborts when stripe cancel fails', function () { + Storage::fake(); + + $owner = User::factory()->create(); + $account = $owner->account; + $accountId = $account->id; + + $member = User::factory()->create(); + $memberPersonalAccountId = $member->account_id; + $member->update(['account_id' => $accountId]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $accountId, + 'user_id' => $owner->id, + ]); + $workspace->members()->attach($owner->id, ['role' => Role::Member->value]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); + + $media = $workspace->addMedia( + UploadedFile::fake()->image('logo.jpg'), + 'logo', + ); + $mediaPath = $media->path; + Storage::assertExists($mediaPath); + + $invite = Invite::factory()->create([ + 'account_id' => $accountId, + 'invited_by' => $owner->id, + 'workspaces' => [$workspace->id], + ]); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + $mockSubscription = Mockery::mock(Subscription::class); + $mockSubscription->shouldReceive('ended')->andReturnFalse(); + $mockSubscription->shouldReceive('cancelNow') + ->once() + ->andThrow(new RuntimeException('stripe unavailable')); + + $mockAccount = Mockery::mock($account)->makePartial(); + $mockAccount->shouldReceive('subscription') + ->with(Account::SUBSCRIPTION_NAME) + ->andReturn($mockSubscription); + $mockAccount->shouldReceive('delete')->never(); + + $owner->setRelation('account', $mockAccount); + + $response = $this->actingAs($owner)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + $response->assertRedirect(route('app.profile.edit')); + $response->assertSessionHas('flash.banner', __('settings.flash.delete_failed_billing')); + $response->assertSessionHas('flash.bannerStyle', 'danger'); + + expect($owner->fresh())->not->toBeNull(); + expect(User::find($member->id))->not->toBeNull(); + expect(Account::find($memberPersonalAccountId))->not->toBeNull(); + expect(Account::find($accountId))->not->toBeNull(); + expect(Account::find($accountId)->subscriptions()->count())->toBe(1); + expect(Workspace::find($workspace->id))->not->toBeNull(); + expect(Media::find($media->id))->not->toBeNull(); + expect(Invite::find($invite->id))->not->toBeNull(); + Storage::assertExists($mediaPath); +}); + +test('account delete cancels incomplete stripe subscriptions that are not subscribed', function () { + $owner = User::factory()->create(); + $account = $owner->account; + $accountId = $account->id; + $member = User::factory()->create(); + $member->update(['account_id' => $accountId]); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_incomplete_'.fake()->uuid(), + 'stripe_status' => 'incomplete', + 'stripe_price' => 'price_123', + ]); + + expect($account->fresh()->subscribed(Account::SUBSCRIPTION_NAME))->toBeFalse(); + + $mockSubscription = Mockery::mock(Subscription::class); + $mockSubscription->shouldReceive('ended')->andReturnFalse(); + $mockSubscription->shouldReceive('cancelNow') + ->once() + ->andThrow(new RuntimeException('stripe unavailable')); + + $mockAccount = Mockery::mock($account)->makePartial(); + $mockAccount->shouldReceive('subscription') + ->with(Account::SUBSCRIPTION_NAME) + ->andReturn($mockSubscription); + $mockAccount->shouldReceive('delete')->never(); + + $owner->setRelation('account', $mockAccount); + + $response = $this->actingAs($owner)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + $response->assertRedirect(route('app.profile.edit')); + $response->assertSessionHas('flash.banner', __('settings.flash.delete_failed_billing')); + + expect($owner->fresh())->not->toBeNull(); + expect(User::find($member->id))->not->toBeNull(); + expect(Account::find($accountId))->not->toBeNull(); +}); diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index e1354dc58..aa09fb86d 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -166,6 +166,62 @@ $response->assertForbidden(); }); +test('switch workspace returns 403 for personal workspace after joining another account', function () { + $sharedOwner = User::factory()->create(); + $invitee = User::factory()->create(); + $personalAccountId = $invitee->account_id; + + $personalWorkspace = Workspace::factory()->create([ + 'account_id' => $personalAccountId, + 'user_id' => $invitee->id, + ]); + $personalWorkspace->members()->attach($invitee->id, ['role' => Role::Admin->value]); + + $sharedWorkspace = Workspace::factory()->create([ + 'account_id' => $sharedOwner->account_id, + 'user_id' => $sharedOwner->id, + ]); + $sharedWorkspace->members()->attach($invitee->id, ['role' => Role::Member->value]); + $invitee->update([ + 'account_id' => $sharedOwner->account_id, + 'current_workspace_id' => $sharedWorkspace->id, + ]); + + $response = $this->actingAs($invitee)->post(route('app.workspaces.switch', $personalWorkspace)); + + $response->assertForbidden(); + expect($invitee->fresh()->current_workspace_id)->toBe($sharedWorkspace->id); +}); + +test('workspace index only lists workspaces on the current account', function () { + $sharedOwner = User::factory()->create(); + $invitee = User::factory()->create(); + $personalAccountId = $invitee->account_id; + + $personalWorkspace = Workspace::factory()->create([ + 'account_id' => $personalAccountId, + 'user_id' => $invitee->id, + ]); + $personalWorkspace->members()->attach($invitee->id, ['role' => Role::Admin->value]); + + $sharedWorkspace = Workspace::factory()->create([ + 'account_id' => $sharedOwner->account_id, + 'user_id' => $sharedOwner->id, + ]); + $sharedWorkspace->members()->attach($invitee->id, ['role' => Role::Member->value]); + $invitee->update([ + 'account_id' => $sharedOwner->account_id, + 'current_workspace_id' => $sharedWorkspace->id, + ]); + + $response = $this->actingAs($invitee)->get(route('app.workspaces.index')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->has('workspaces', 1) + ->where('workspaces.0.id', $sharedWorkspace->id)); +}); + // Settings tests test('workspace settings requires authentication', function () { $response = $this->get(route('app.workspace.settings')); @@ -180,6 +236,72 @@ $response->assertInertia(fn ($page) => $page ->component('settings/workspace/Workspace', false) ->has('workspace') + ->where('isOnlyWorkspace', false) + ->where('otherMemberCount', 0) + ); +}); + +test('workspace settings marks only workspace in saas mode', function () { + config(['trypost.self_hosted' => false]); + $this->user->account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + $response = $this->actingAs($this->user)->get(route('app.workspace.settings')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('isOnlyWorkspace', true) + ); +}); + +test('workspace settings does not mark only workspace when account has more than one', function () { + config(['trypost.self_hosted' => false]); + $this->user->account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + + $response = $this->actingAs($this->user)->get(route('app.workspace.settings')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('isOnlyWorkspace', false) + ); +}); + +test('workspace settings otherMemberCount only includes members without another account workspace', function () { + $stranded = User::factory()->create([ + 'account_id' => $this->user->account_id, + ]); + $alsoOnOther = User::factory()->create([ + 'account_id' => $this->user->account_id, + ]); + + $otherWorkspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + + $this->workspace->members()->attach($stranded->id, ['role' => Role::Member->value]); + $this->workspace->members()->attach($alsoOnOther->id, ['role' => Role::Member->value]); + $otherWorkspace->members()->attach($alsoOnOther->id, ['role' => Role::Member->value]); + + $response = $this->actingAs($this->user)->get(route('app.workspace.settings')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where('otherMemberCount', 1) ); }); @@ -439,12 +561,13 @@ $response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace)); - $response->assertRedirect(route('app.workspaces.index')); + $response->assertRedirect(route('app.calendar')); + $response->assertSessionHas('flash.success', __('workspaces.flash.deleted')); expect(Workspace::find($workspaceId))->toBeNull(); }); -test('destroy workspace clears current workspace if deleting current', function () { - Workspace::factory()->create([ +test('destroy workspace falls back to another account workspace when deleting current', function () { + $other = Workspace::factory()->create([ 'account_id' => $this->user->account_id, 'user_id' => $this->user->id, ]); @@ -452,7 +575,8 @@ $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace)); $this->user->refresh(); - expect($this->user->current_workspace_id)->toBeNull(); + expect($this->user->current_workspace_id)->toBe($other->id); + expect($this->user->belongsToWorkspace($other))->toBeTrue(); }); test('destroy workspace reassigns current to another joined workspace', function () { @@ -487,8 +611,10 @@ $response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace)); - $response->assertRedirect(route('app.workspaces.index')); + $response->assertRedirect(route('app.workspaces.create')); + $response->assertSessionHas('flash.success', __('workspaces.flash.deleted')); expect(Workspace::find($workspaceId))->toBeNull(); + expect($this->user->fresh()->current_workspace_id)->toBeNull(); }); test('destroy workspace returns 403 for non-owner', function () { @@ -508,6 +634,42 @@ $response->assertForbidden(); }); +test('destroy workspace returns 403 for workspace admin', function () { + Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + + $admin = User::factory()->create([ + 'account_id' => $this->user->account_id, + 'current_workspace_id' => $this->workspace->id, + ]); + $this->workspace->members()->attach($admin->id, ['role' => Role::Admin->value]); + + $response = $this->actingAs($admin)->delete(route('app.workspaces.destroy', $this->workspace)); + + $response->assertForbidden(); + expect(Workspace::find($this->workspace->id))->not->toBeNull(); +}); + +test('destroy workspace returns 403 for workspace member', function () { + Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + + $member = User::factory()->create([ + 'account_id' => $this->user->account_id, + 'current_workspace_id' => $this->workspace->id, + ]); + $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); + + $response = $this->actingAs($member)->delete(route('app.workspaces.destroy', $this->workspace)); + + $response->assertForbidden(); + expect(Workspace::find($this->workspace->id))->not->toBeNull(); +}); + // Autofill brand tests test('autofillBrand returns metadata without persisting anything', function () { $account = Account::factory()->create(); diff --git a/tests/Feature/WorkspaceInviteControllerTest.php b/tests/Feature/WorkspaceInviteControllerTest.php index bff211939..f71bfccc4 100644 --- a/tests/Feature/WorkspaceInviteControllerTest.php +++ b/tests/Feature/WorkspaceInviteControllerTest.php @@ -193,6 +193,24 @@ expect($this->workspace->hasMember($member))->toBeFalse(); }); +test('remove member deletes stranded members and empty personal accounts', function () { + [ + 'member' => $member, + 'personal_account_id' => $personalAccountId, + ] = strandedMemberOnSharedAccount( + owner: $this->user, + setMemberCurrent: false, + ); + $member->update(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]); + + $this->actingAs($this->user)->delete(route('app.members.remove', $member)); + + expect($this->workspace->hasMember($member))->toBeFalse(); + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); +}); + test('remove member fails for owner', function () { $response = $this->actingAs($this->user)->delete(route('app.members.remove', $this->user)); diff --git a/tests/Pest.php b/tests/Pest.php index c99d46bf0..d659bf9e8 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -160,3 +160,80 @@ function subscribeAccount(Account $account): void 'stripe_price' => 'price_123', ]); } + +/** + * Move a member onto a shared account (stranded-member / invitee fixture). + * + * @return array{ + * owner: User, + * member: User, + * personal_account_id: string, + * personal_workspace: ?Workspace, + * shared_workspaces: list + * } + */ +function strandedMemberOnSharedAccount( + bool $withPersonalWorkspace = false, + int $sharedWorkspaces = 0, + bool $attachMember = true, + bool $attachMemberToAll = true, + bool $attachOwner = true, + bool $setMemberCurrent = false, + ?User $owner = null, + ?string $memberEmail = null, +): array { + $owner ??= User::factory()->create(); + $member = User::factory()->create(array_filter([ + 'email' => $memberEmail, + ])); + $personalAccountId = $member->account_id; + $personalWorkspace = null; + + if ($withPersonalWorkspace) { + $personalWorkspace = Workspace::factory()->create([ + 'account_id' => $personalAccountId, + 'user_id' => $member->id, + ]); + $personalWorkspace->members()->attach($member->id, [ + 'role' => Role::Admin->value, + ]); + } + + $shared = []; + + for ($i = 0; $i < $sharedWorkspaces; $i++) { + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + + if ($attachOwner) { + $workspace->members()->syncWithoutDetaching([ + $owner->id => ['role' => Role::Admin->value], + ]); + } + + if ($attachMember && ($attachMemberToAll || $i === 0)) { + $workspace->members()->attach($member->id, [ + 'role' => Role::Member->value, + ]); + } + + $shared[] = $workspace; + } + + $member->update([ + 'account_id' => $owner->account_id, + 'current_workspace_id' => ($setMemberCurrent && $shared !== []) + ? $shared[0]->id + : null, + ]); + + return [ + 'owner' => $owner->fresh(), + 'member' => $member->fresh(), + 'personal_account_id' => $personalAccountId, + 'personal_workspace' => $personalWorkspace, + 'shared_workspaces' => $shared, + ]; +} diff --git a/tests/Unit/Actions/Account/AccountsRequiringCancelTest.php b/tests/Unit/Actions/Account/AccountsRequiringCancelTest.php new file mode 100644 index 000000000..217e1f3b6 --- /dev/null +++ b/tests/Unit/Actions/Account/AccountsRequiringCancelTest.php @@ -0,0 +1,34 @@ + $owner, + 'personal_account_id' => $personalAccountId, + ] = strandedMemberOnSharedAccount(withPersonalWorkspace: true); + + $ids = AccountsRequiringCancel::forDeletingUser($owner, $owner->account, true) + ->pluck('id') + ->all(); + + expect($ids)->toContain($personalAccountId) + ->and($ids)->toContain($owner->account_id) + ->and(end($ids))->toBe($owner->account_id) + ->and(array_search($personalAccountId, $ids, true)) + ->toBeLessThan(array_search($owner->account_id, $ids, true)); +}); + +test('member delete preflight only includes accounts they own', function () { + [ + 'owner' => $owner, + 'member' => $member, + 'personal_account_id' => $personalAccountId, + ] = strandedMemberOnSharedAccount(); + + $accounts = AccountsRequiringCancel::forDeletingUser($member, $owner->account, false); + + expect($accounts->pluck('id')->all())->toBe([$personalAccountId]); +}); diff --git a/tests/Unit/Actions/Account/CancelAccountSubscriptionTest.php b/tests/Unit/Actions/Account/CancelAccountSubscriptionTest.php new file mode 100644 index 000000000..9335d3ea0 --- /dev/null +++ b/tests/Unit/Actions/Account/CancelAccountSubscriptionTest.php @@ -0,0 +1,55 @@ +create(); + + expect(CancelAccountSubscription::execute($owner->account))->toBeTrue(); +}); + +test('cancel returns false when stripe cancelNow fails', function () { + $owner = User::factory()->create(); + $account = $owner->account; + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + $mockSubscription = Mockery::mock(Subscription::class); + $mockSubscription->shouldReceive('ended')->andReturnFalse(); + $mockSubscription->shouldReceive('cancelNow') + ->once() + ->andThrow(new RuntimeException('stripe unavailable')); + + $mockAccount = Mockery::mock($account)->makePartial(); + $mockAccount->shouldReceive('subscription') + ->with(Account::SUBSCRIPTION_NAME) + ->andReturn($mockSubscription); + + expect(CancelAccountSubscription::execute($mockAccount))->toBeFalse(); +}); + +test('cancel skips ended subscriptions', function () { + $owner = User::factory()->create(); + $account = $owner->account; + + $mockSubscription = Mockery::mock(Subscription::class); + $mockSubscription->shouldReceive('ended')->andReturnTrue(); + $mockSubscription->shouldReceive('cancelNow')->never(); + + $mockAccount = Mockery::mock($account)->makePartial(); + $mockAccount->shouldReceive('subscription') + ->with(Account::SUBSCRIPTION_NAME) + ->andReturn($mockSubscription); + + expect(CancelAccountSubscription::execute($mockAccount))->toBeTrue(); +}); diff --git a/tests/Unit/Actions/Account/CancelAccountsTest.php b/tests/Unit/Actions/Account/CancelAccountsTest.php new file mode 100644 index 000000000..9dab84482 --- /dev/null +++ b/tests/Unit/Actions/Account/CancelAccountsTest.php @@ -0,0 +1,42 @@ +create(); + + expect(CancelAccounts::execute([$owner->account]))->toBeTrue(); +}); + +test('cancel accounts stops on the first failure', function () { + $first = User::factory()->create()->account; + $second = User::factory()->create()->account; + + $first->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_fail_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); + + $mockSubscription = Mockery::mock(Subscription::class); + $mockSubscription->shouldReceive('ended')->andReturnFalse(); + $mockSubscription->shouldReceive('cancelNow') + ->once() + ->andThrow(new RuntimeException('stripe unavailable')); + + $mockFirst = Mockery::mock($first)->makePartial(); + $mockFirst->shouldReceive('subscription') + ->with(Account::SUBSCRIPTION_NAME) + ->andReturn($mockSubscription); + + $mockSecond = Mockery::mock($second)->makePartial(); + $mockSecond->shouldReceive('subscription')->never(); + + expect(CancelAccounts::execute([$mockFirst, $mockSecond]))->toBeFalse(); +}); diff --git a/tests/Unit/Actions/Account/DeleteEmptyOwnedAccountsTest.php b/tests/Unit/Actions/Account/DeleteEmptyOwnedAccountsTest.php new file mode 100644 index 000000000..839d40dc4 --- /dev/null +++ b/tests/Unit/Actions/Account/DeleteEmptyOwnedAccountsTest.php @@ -0,0 +1,81 @@ +create(); + $emptyAccountId = $user->account_id; + + expect(Workspace::query()->where('account_id', $emptyAccountId)->count())->toBe(0); + + $deleted = DeleteEmptyOwnedAccounts::execute($user); + + expect($deleted)->toContain($emptyAccountId); + expect(Account::find($emptyAccountId))->toBeNull(); + expect($user->fresh()->account_id)->toBeNull(); +}); + +test('keeps accounts that still have workspaces', function () { + $user = User::factory()->create(); + $accountId = $user->account_id; + + $workspace = Workspace::factory()->create([ + 'account_id' => $accountId, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + + $deleted = DeleteEmptyOwnedAccounts::execute($user); + + expect($deleted)->toBeEmpty(); + expect(Account::find($accountId))->not->toBeNull(); +}); + +test('skips the excepted account id', function () { + $user = User::factory()->create(); + $emptyAccountId = $user->account_id; + + $deleted = DeleteEmptyOwnedAccounts::execute( + $user, + exceptAccountId: $emptyAccountId, + ); + + expect($deleted)->toBeEmpty(); + expect(Account::find($emptyAccountId))->not->toBeNull(); +}); + +test('only deletes the targeted account id', function () { + $user = User::factory()->create(); + $firstId = $user->account_id; + + $second = Account::factory()->create(['owner_id' => $user->id]); + + $deleted = DeleteEmptyOwnedAccounts::execute( + $user, + onlyAccountId: $second->id, + ); + + expect($deleted)->toBe([$second->id]); + expect(Account::find($second->id))->toBeNull(); + expect(Account::find($firstId))->not->toBeNull(); +}); + +test('executeByIds deletes accounts after the owner user is gone', function () { + $user = User::factory()->create(); + $emptyAccountId = $user->account_id; + + $user->delete(); + + expect(Account::find($emptyAccountId))->not->toBeNull(); + + $deleted = DeleteEmptyOwnedAccounts::executeByIds([$emptyAccountId]); + + expect($deleted)->toBe([$emptyAccountId]); + expect(Account::find($emptyAccountId))->toBeNull(); +}); diff --git a/tests/Unit/Actions/Auth/LogoutAndInvalidateSessionTest.php b/tests/Unit/Actions/Auth/LogoutAndInvalidateSessionTest.php new file mode 100644 index 000000000..aa8ef8a27 --- /dev/null +++ b/tests/Unit/Actions/Auth/LogoutAndInvalidateSessionTest.php @@ -0,0 +1,27 @@ +create(); + + $this->actingAs($user); + + $request = request(); + $request->setLaravelSession(app('session.store')); + + expect(Auth::check())->toBeTrue(); + + LogoutAndInvalidateSession::execute($request, [ + 'banner' => 'gone', + 'bannerStyle' => 'danger', + ]); + + expect(Auth::check())->toBeFalse(); + expect(session('flash.banner'))->toBe('gone'); + expect(session('flash.bannerStyle'))->toBe('danger'); +}); diff --git a/tests/Unit/Actions/User/SettleStrandedMemberTest.php b/tests/Unit/Actions/User/SettleStrandedMemberTest.php new file mode 100644 index 000000000..92d4fcc6f --- /dev/null +++ b/tests/Unit/Actions/User/SettleStrandedMemberTest.php @@ -0,0 +1,136 @@ + $owner, 'member' => $member, 'personal_account_id' => $personalAccountId] = strandedMemberOnSharedAccount(); + + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); + + SettleStrandedMember::execute($member->fresh(), $owner->account); + + expect(User::find($member->id))->not->toBeNull(); + expect($member->fresh()->account_id)->toBe($owner->account_id); + expect(Account::find($personalAccountId))->not->toBeNull(); +}); + +test('no-ops for the account owner', function () { + $owner = User::factory()->create(); + + SettleStrandedMember::execute($owner->fresh(), $owner->account); + + expect(User::find($owner->id))->not->toBeNull(); + expect(Account::find($owner->account_id))->not->toBeNull(); +}); + +test('deletes a stranded invitee and flushes empty personal account after settle', function () { + ['owner' => $owner, 'member' => $member, 'personal_account_id' => $personalAccountId] = strandedMemberOnSharedAccount(); + + $settled = SettleStrandedMember::execute($member->fresh(), $owner->account); + + expect(User::find($member->id))->toBeNull(); + expect($settled->emptyAccountIds)->toContain($personalAccountId); + expect(Account::find($personalAccountId))->not->toBeNull(); + + $settled->flush(); + + expect(Account::find($personalAccountId))->toBeNull(); +}); + +test('restores a personal account that still has a workspace', function () { + ['owner' => $owner, 'member' => $member, 'personal_account_id' => $personalAccountId, 'personal_workspace' => $personalWorkspace] = strandedMemberOnSharedAccount(withPersonalWorkspace: true); + + $settled = SettleStrandedMember::execute($member->fresh(), $owner->account); + + $member->refresh(); + + expect($member->account_id)->toBe($personalAccountId); + expect($member->current_workspace_id)->toBe($personalWorkspace->id); + expect($member->isAccountOwner())->toBeTrue(); + expect($settled->emptyAccountIds)->toBeEmpty(); +}); + +test('deletes stranded invitee and revokes their passport tokens', function () { + ['owner' => $owner, 'member' => $member] = strandedMemberOnSharedAccount(); + + $token = $member->createToken('API Key')->token; + expect($token->revoked)->toBeFalse(); + + SettleStrandedMember::execute($member->fresh(), $owner->account); + + expect(User::find($member->id))->toBeNull(); + expect(AccessToken::find($token->id)->revoked)->toBeTrue(); +}); + +test('deletes stranded invitee avatar media files from storage', function () { + Storage::fake(); + + ['owner' => $owner, 'member' => $member] = strandedMemberOnSharedAccount(); + + $avatar = $member->addMedia( + UploadedFile::fake()->image('avatar.jpg', 200, 200), + 'avatar', + ); + $avatarPath = $avatar->path; + Storage::assertExists($avatarPath); + + SettleStrandedMember::execute($member->fresh(), $owner->account)->flush(); + + expect(User::find($member->id))->toBeNull(); + expect(Media::find($avatar->id))->toBeNull(); + Storage::assertMissing($avatarPath); +}); + +test('forceDelete removes a member who still owns a personal workspace', function () { + ['owner' => $owner, 'member' => $member, 'personal_account_id' => $personalAccountId, 'personal_workspace' => $personalWorkspace] = strandedMemberOnSharedAccount(withPersonalWorkspace: true); + + SettleStrandedMember::forceDelete($member->fresh(), $owner->account); + + expect(User::find($member->id))->toBeNull(); + expect(Account::find($personalAccountId))->toBeNull(); + expect(Workspace::find($personalWorkspace->id))->toBeNull(); +}); + +test('strandedWithoutMemberships only processes users without remaining account workspaces', function () { + [ + 'owner' => $owner, + 'member' => $stranded, + 'personal_account_id' => $strandedPersonalId, + 'shared_workspaces' => [$workspace], + ] = strandedMemberOnSharedAccount( + sharedWorkspaces: 1, + attachMember: false, + ); + + [ + 'member' => $stillMember, + ] = strandedMemberOnSharedAccount( + owner: $owner, + sharedWorkspaces: 0, + ); + $workspace->members()->attach($stillMember->id, ['role' => Role::Member->value]); + + SettleStrandedMember::strandedWithoutMemberships( + $owner->account, + exceptUserId: $owner->id, + )->flush(); + + expect(User::find($stranded->id))->toBeNull(); + expect(Account::find($strandedPersonalId))->toBeNull(); + expect(User::find($stillMember->id))->not->toBeNull(); + expect($stillMember->fresh()->account_id)->toBe($owner->account_id); +}); diff --git a/tests/Unit/Actions/User/StrandedSettlementTest.php b/tests/Unit/Actions/User/StrandedSettlementTest.php new file mode 100644 index 000000000..9d82486b9 --- /dev/null +++ b/tests/Unit/Actions/User/StrandedSettlementTest.php @@ -0,0 +1,30 @@ +create(); + $emptyAccountId = $user->account_id; + $user->delete(); + + (new StrandedSettlement(emptyAccountIds: [$emptyAccountId]))->flush(); + + expect(Account::find($emptyAccountId))->toBeNull(); +}); + +test('merge combines media paths and unique empty account ids', function () { + $merged = (new StrandedSettlement( + mediaPaths: ['a.jpg'], + emptyAccountIds: ['1', '2'], + ))->merge(new StrandedSettlement( + mediaPaths: ['b.jpg'], + emptyAccountIds: ['2', '3'], + )); + + expect($merged->mediaPaths)->toBe(['a.jpg', 'b.jpg']); + expect($merged->emptyAccountIds)->toBe(['1', '2', '3']); +}); diff --git a/tests/Unit/Actions/Workspace/PurgeWorkspaceTest.php b/tests/Unit/Actions/Workspace/PurgeWorkspaceTest.php new file mode 100644 index 000000000..434c59d97 --- /dev/null +++ b/tests/Unit/Actions/Workspace/PurgeWorkspaceTest.php @@ -0,0 +1,33 @@ +create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + + $media = $workspace->addMedia( + UploadedFile::fake()->image('logo.jpg'), + 'logo', + ); + $path = $media->path; + + $paths = PurgeWorkspace::execute($workspace); + + expect($paths)->toBe([$path]); + expect(Workspace::find($workspace->id))->toBeNull(); + expect(Media::find($media->id))->toBeNull(); + Storage::assertExists($path); +}); diff --git a/tests/Unit/Enums/Invite/ResultTest.php b/tests/Unit/Enums/Invite/ResultTest.php new file mode 100644 index 000000000..4e77e8d7f --- /dev/null +++ b/tests/Unit/Enums/Invite/ResultTest.php @@ -0,0 +1,17 @@ +flashBanner())->toBe(__($bannerKey)); + expect($result->flashStyle())->toBe($style); +})->with([ + 'wrong email' => [Result::WrongEmail, 'settings.members.flash.wrong_email', 'danger'], + 'gone' => [Result::Gone, 'settings.members.flash.invite_workspace_gone', 'danger'], + 'already accepted' => [Result::AlreadyAccepted, 'settings.members.flash.already_member', 'info'], + 'already member' => [Result::AlreadyMember, 'settings.members.flash.already_member', 'info'], + 'accepted' => [Result::Accepted, 'settings.members.flash.invite_accepted', 'success'], + 'declined' => [Result::Declined, 'settings.members.flash.invite_declined', 'info'], +]); diff --git a/tests/Unit/Policies/WorkspacePolicyTest.php b/tests/Unit/Policies/WorkspacePolicyTest.php index 7bc3bd451..6396f9df9 100644 --- a/tests/Unit/Policies/WorkspacePolicyTest.php +++ b/tests/Unit/Policies/WorkspacePolicyTest.php @@ -133,7 +133,7 @@ expect($this->policy->update($member, $workspace))->toBeFalse(); }); -test('only account owner can delete workspace', function () { +test('account owner can delete workspace but workspace admin cannot', function () { $account = Account::factory()->create(); $owner = User::factory()->create([ 'account_id' => $account->id, @@ -142,16 +142,22 @@ $admin = User::factory()->create([ 'account_id' => $account->id, ]); + $member = User::factory()->create([ + 'account_id' => $account->id, + ]); $workspace = Workspace::factory()->create([ 'account_id' => $account->id, 'user_id' => $owner->id, ]); + $workspace->members()->attach($admin->id, ['role' => Role::Admin->value]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); expect($this->policy->delete($owner, $workspace))->toBeTrue(); expect($this->policy->delete($admin, $workspace))->toBeFalse(); + expect($this->policy->delete($member, $workspace))->toBeFalse(); }); -test('only account owner can restore workspace', function () { +test('account owner can restore workspace but workspace admin cannot', function () { $account = Account::factory()->create(); $owner = User::factory()->create([ 'account_id' => $account->id, @@ -164,12 +170,13 @@ 'account_id' => $account->id, 'user_id' => $owner->id, ]); + $workspace->members()->attach($admin->id, ['role' => Role::Admin->value]); expect($this->policy->restore($owner, $workspace))->toBeTrue(); expect($this->policy->restore($admin, $workspace))->toBeFalse(); }); -test('only account owner can force delete workspace', function () { +test('account owner can force delete workspace but workspace admin cannot', function () { $account = Account::factory()->create(); $owner = User::factory()->create([ 'account_id' => $account->id, @@ -182,6 +189,7 @@ 'account_id' => $account->id, 'user_id' => $owner->id, ]); + $workspace->members()->attach($admin->id, ['role' => Role::Admin->value]); expect($this->policy->forceDelete($owner, $workspace))->toBeTrue(); expect($this->policy->forceDelete($admin, $workspace))->toBeFalse(); diff --git a/tests/Unit/Settings/DeleteAccountCopyTest.php b/tests/Unit/Settings/DeleteAccountCopyTest.php new file mode 100644 index 000000000..6b9d29c5f --- /dev/null +++ b/tests/Unit/Settings/DeleteAccountCopyTest.php @@ -0,0 +1,120 @@ + + */ +$accountDeleteInvitedMemberMarkers = [ + 'en' => 'invited members', + 'pt-BR' => 'membros convidados', + 'es' => 'miembros invitados', + 'fr' => 'membres invités', + 'de' => 'eingeladene Mitglieder', + 'it' => 'membri invitati', + 'nl' => 'uitgenodigde leden', + 'pl' => 'zaproszeni członkowie', + 'el' => 'προσκεκλημένα μέλη', + 'ja' => '招待されたメンバー', + 'ko' => '초대된 멤버', + 'zh' => '受邀成员', + 'ru' => 'приглашённые участники', + 'tr' => 'davet edilen üyeler', + 'ar' => 'الأعضاء المدعوون', +]; + +/** + * @var array + */ +$workspaceDeleteConditionalMemberMarkers = [ + 'en' => 'without another TryPost workspace', + 'pt-BR' => 'sem outro workspace no TryPost', + 'es' => 'sin otro workspace en TryPost', + 'fr' => 'sans autre workspace TryPost', + 'de' => 'ohne anderen TryPost-Workspace', + 'it' => 'senza un altro workspace TryPost', + 'nl' => 'zonder andere TryPost-workspace', + 'pl' => 'bez innego workspace w TryPost', + 'el' => 'χωρίς άλλο workspace στο TryPost', + 'ja' => '別のTryPostワークスペースがない', + 'ko' => '다른 TryPost 워크스페이스가 없는', + 'zh' => '没有其他 TryPost 工作区', + 'ru' => 'без другого workspace в TryPost', + 'tr' => 'Başka bir TryPost workspace', + 'ar' => 'مساحة عمل أخرى في TryPost', +]; + +test('workspace delete members warning describes conditional permanent deletion', function () { + $warning = trans_choice('settings.workspace.delete_members_warning', 1, ['count' => 1]); + + expect($warning) + ->toContain('lose access') + ->toContain('without another TryPost workspace') + ->toContain('permanently deleted') + ->not->toContain('personal account'); +}); + +test('account delete billing failure flash says nothing was deleted', function () { + $flash = __('settings.flash.delete_failed_billing'); + + expect($flash) + ->toContain('Nothing was deleted') + ->not->toContain('already removed'); +}); + +test('account delete warning mentions invited members are permanently deleted', function () { + expect(__('settings.delete_account.warning_message')) + ->toContain('invited members') + ->toContain('permanently deleted'); + + expect(__('settings.delete_account.modal_description_password')) + ->toContain('invited members') + ->toContain('permanently deleted'); +}); + +test('account delete modals mention invited members in every locale', function (string $locale, string $needle) { + expect(__('settings.delete_account.modal_description_password', [], $locale)) + ->toContain($needle); + + expect(__('settings.delete_account.modal_description_email', ['email' => 'x@y.z'], $locale)) + ->toContain($needle); + + expect(__('settings.delete_account.warning_message', [], $locale)) + ->toContain($needle); +})->with( + collect($accountDeleteInvitedMemberMarkers) + ->map(fn (string $needle, string $locale): array => [$locale, $needle]) + ->all() +); + +test('workspace delete members warning is conditional in every locale', function (string $locale, string $needle) { + $warning = trans_choice( + 'settings.workspace.delete_members_warning', + 2, + ['count' => 2], + $locale, + ); + + expect($warning)->toContain($needle); +})->with( + collect($workspaceDeleteConditionalMemberMarkers) + ->map(fn (string $needle, string $locale): array => [$locale, $needle]) + ->all() +); + +test('destructive copy locale markers cover every ContentLanguage', function () use ( + $accountDeleteInvitedMemberMarkers, + $workspaceDeleteConditionalMemberMarkers, +) { + expect(array_keys($accountDeleteInvitedMemberMarkers)) + ->toEqualCanonicalizing(ContentLanguage::values()); + + expect(array_keys($workspaceDeleteConditionalMemberMarkers)) + ->toEqualCanonicalizing(ContentLanguage::values()); +}); diff --git a/tests/Unit/Traits/HasWorkspaceTest.php b/tests/Unit/Traits/HasWorkspaceTest.php index 86f130014..68ebe96b9 100644 --- a/tests/Unit/Traits/HasWorkspaceTest.php +++ b/tests/Unit/Traits/HasWorkspaceTest.php @@ -48,6 +48,32 @@ expect($user->fresh()->current_workspace_id)->toBe($workspace2->id); }); +test('user belongs to member workspace on the same account', function () { + $owner = User::factory()->create(); + $member = User::factory()->create(); + $member->update(['account_id' => $owner->account_id]); + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); + + expect($member->belongsToWorkspace($workspace))->toBeTrue(); +}); + +test('user does not belong to a workspace on another account even with a pivot', function () { + $owner = User::factory()->create(); + $member = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); + + expect($member->account_id)->not->toBe($workspace->account_id); + expect($member->belongsToWorkspace($workspace))->toBeFalse(); +}); + test('user belongs to owned workspace', function () { $user = User::factory()->create(); $workspace = Workspace::factory()->create(['user_id' => $user->id]); @@ -56,13 +82,26 @@ expect($user->belongsToWorkspace($workspace))->toBeTrue(); }); -test('user belongs to member workspace', function () { - $owner = User::factory()->create(); - $member = User::factory()->create(); - $workspace = Workspace::factory()->create(['user_id' => $owner->id]); - $workspace->members()->attach($member->id, ['role' => Role::Member->value]); - - expect($member->belongsToWorkspace($workspace))->toBeTrue(); +test('accountWorkspaces excludes memberships on other accounts', function () { + $sharedOwner = User::factory()->create(); + $user = User::factory()->create(); + $personalAccountId = $user->account_id; + + $personalWorkspace = Workspace::factory()->create([ + 'account_id' => $personalAccountId, + 'user_id' => $user->id, + ]); + $personalWorkspace->members()->attach($user->id, ['role' => Role::Admin->value]); + + $sharedWorkspace = Workspace::factory()->create([ + 'account_id' => $sharedOwner->account_id, + 'user_id' => $sharedOwner->id, + ]); + $sharedWorkspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['account_id' => $sharedOwner->account_id]); + + expect($user->fresh()->accountWorkspaces()->pluck('workspaces.id')->all()) + ->toEqualCanonicalizing([$sharedWorkspace->id]); }); test('user does not belong to other workspace', function () {