Allow account owners to delete workspaces (#208)
* Allow owners and admins to delete workspaces from settings.
Expose a danger zone with name confirmation, sync Stripe quantity on SaaS, and skip billing constraints in self-hosted mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant canDelete prop from workspace settings.
The settings page is already gated by update (owner/admin), which matches delete.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract workspace delete danger zone into DeleteWorkspace component.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify workspace delete billing copy across locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match workspace delete card to the delete-account settings pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden workspace and account deletion around shared members.
Enforce owner-only workspace creation, rehome stranded members to a personal account, warn about member access loss, and clarify the only-workspace SaaS exit paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden workspace delete: owner-only billing impact and safer member rehome.
Restrict delete to account owners, rehome stranded members transactionally with account-scoped fallbacks, and clean up the danger-zone UI/copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix workspace delete review findings.
Prune pending invites and media on delete, lock the account for the
last-workspace guard, fall back to account-owned workspaces for owners,
redirect self-hosted last deletes to create, cancel Stripe after local
cleanup, align personal-account trials, and gate Index create for owners.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Harden invite accept and account delete edge cases.
Stop invite accept from demoting existing roles, expire dead invites on
show, preserve flash by avoiding calendar bounces, move media file I/O
outside locked delete transactions, and finish account deletion even if
Stripe cancel fails.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix remaining invite redirect and media cleanup edge cases.
Distinguish already-accepted invites from gone workspaces, rehome
members removed from their last shared workspace, capture media paths
inside the delete lock, extract orphaned-file cleanup, and use Wayfinder
for the expired-invite home link.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix invite current-workspace and account-delete edge cases.
Switch invitees onto an invite-account workspace when accepting, prefer
same-account fallbacks when removing members, abort account deletion if
Stripe cancel fails, and clear avatar media on profile delete.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix Stripe-failure media leak and invite cross-account redirect.
Flush workspace media files before billing cancel can abort account
delete, and rehome stranded non-owners before picking an invite redirect
fallback so current workspace never points across accounts.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Never set cross-account current workspace on member rehome.
Keep RemoveMember and account-delete member fallbacks same-account
only, clarify the billing-failure flash that workspaces were already
removed, and assert storage deletion in media cleanup tests.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Sync Stripe workspace quantity when account delete billing fails.
After local workspaces are wiped, a stuck cancelNow must still drop
seat quantity so the subscription cannot keep billing the old count.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Prune account invites when owner delete wipes workspaces.
Pending and accepted invites are removed with the workspaces so a
Stripe cancel failure cannot leave unique email/account rows that block
re-invites to a gutted account.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Extract DeleteWorkspaceMedia to purge workspace media rows.
Call sites capture returned paths inside the lock and still flush
orphaned storage files after commit via DeleteOrphanedMediaFiles.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Redirect to calendar after deleting a workspace with a fallback.
When DeleteWorkspace already sets another current workspace, sending
the owner to the workspace picker is unnecessary — take them back into
the app instead.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Use Wayfinder for invite redirect and logo home links.
Replace hardcoded /invites/{id} and / hrefs in AcceptInvite with
show.url() and home() route helpers.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Use Wayfinder home() for AcceptInvite logo link.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Extract AcceptInvite title and description into computeds.
Keeps the expired/active copy logic out of the template and matches
the existing trans() pattern used elsewhere.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix lazy-loading crash when deleting a workspace.
isAccountOwner() no longer touches the account relation unless it is
already loaded, and delete/rehome queries eager-load account when they
need ownership checks under Model::shouldBeStrict().
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Avoid isAccountOwner during workspace delete fallback.
Compare against the already-loaded account owner_id so current-workspace
reassignment cannot touch the account relation under shouldBeStrict().
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Add tests for DeleteWorkspace functionality
Introduce comprehensive tests for the DeleteWorkspace action, covering scenarios such as deleting stranded members, handling multiple workspaces, restoring members with personal workspaces, and managing invites. Ensure that workspace media files are deleted and verify behavior when the last workspace is blocked by SaaS settings. This enhances the reliability of workspace deletion processes and ensures proper account management during deletions.
* Refactor member removal process to delete or restore stranded members
Updated the RemoveMember action to utilize the new DeleteOrRestoreStrandedMember class, which handles the deletion of stranded members or restoration to personal accounts. This change improves the management of user accounts when members are removed from workspaces, ensuring that non-owner members are properly handled based on their account status. Additionally, tests have been updated to reflect these changes, ensuring that the functionality works as intended.
* Enhance member removal and media management during account deletion
Updated the RemoveMember action to collect media paths for orphaned files when removing members. Integrated the DeleteOrphanedMediaFiles action to ensure that any media associated with deleted users is properly purged. Additionally, refactored the DeleteOrRestoreStrandedMember class to return media paths for cleanup, improving overall resource management during user account deletions. This change ensures that all orphaned media files are handled efficiently, maintaining system integrity.
* Enhance user account deletion process with force delete option
Updated the DeleteOrRestoreStrandedMember class to include a forceDelete parameter, allowing for immediate deletion of members and their associated personal accounts and workspaces. This change ensures that when an account is forcefully deleted, all remnants of the user's data are purged, improving data integrity and resource management. Additionally, updated related methods and tests to accommodate this new functionality, ensuring comprehensive coverage and correct behavior during account deletions.
* Extract shared delete/invite actions out of fat controllers.
Centralize workspace/account/user teardown and invite accept/decline so ProfileController and AcceptInviteController stay thin HTTP wrappers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden delete/invite invariants and replace invite string outcomes.
Block cross-account workspace listing/switching, cancel Stripe on owned accounts before purge, lock RemoveMember, fold owner fallback into ReassignCurrentWorkspace, and type invite results with an enum.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Polish delete/invite teardown APIs and cancel Stripe on empty accounts.
Extract DeleteEmptyOwnedAccounts, rename settle-after-invite, and expose
clearer stranded-member entry points so cancel never races the invite lock.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Finish stranded teardown craft: settle outside locks, clearer names.
Defer empty-account Stripe cancel until after the account lock, rename
stranded handling to SettleStrandedMember, and extract AccountsRequiringCancel.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden multi-account Stripe cancel order and typed stranded settlements.
Cancel member personals before the shared account, introduce CancelAccounts
and StrandedSettlement::flush so partial Stripe failures leave billing intact.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Reuse strandedMemberOnSharedAccount across delete/invite feature tests.
Expand the Pest helper for shared workspaces and owner injection so
stranded-member fixtures stop being hand-rolled in every suite.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Lock the account row during owner account teardown.
Serialize DeleteAccount with DeleteWorkspace/RemoveMember so concurrent
stranded restores cannot move members off the account before force-delete.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop personal-account restore when leaving a shared account.
Invitees abandon their previous personal account on accept, and stranded
members are always deleted — matching the real product flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Close the account model and consolidate teardown actions.
Block invites to emails that already belong to a registered user — accounts
are closed (one user, one account), so members never own a personal account.
This removes the whole leftover/restore surface.
Consolidate: fold AccountsRequiringCancel/CancelAccounts into
CancelAccountSubscription, drop DeleteEmptyOwnedAccounts/DeleteOwnedAccount/
PurgeOwnedAccounts, and fold DeleteAccount into DeleteUser. 23 -> 15 new
action files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove orphaned members.errors.already_member translation key.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Block invitees from creating a workspace on the invite shell.
A pending invitee could open workspaces/create (outside EnsureHasWorkspace)
and add a workspace (then billing) on their empty signup shell before accept.
Accept only tears down an empty shell, so this left an abandoned, billable
account. Deny create/store while an invite is pending — the invitee joins via
the invite instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten stranded-member fixtures to the closed-account model.
Drop the member's empty signup shell in strandedMemberOnSharedAccount and the
billing-abort profile test so the setup matches what accept actually leaves
(member owns nothing). Remove the never-overridden attachOwner param.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bind invite registration to the invited email.
The register form shows the invited email as read-only when an invite id is
present, and store() rejects a different email for a valid invite. Also fixes
a latent bug: EnsureRegistrationEnabled only read the invite id from the query
string, so the self-hosted invite registration POST always 404'd.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move register validation into RegisterRequest.
Inline $request->validate() and the invite-email check move into
App\Http\Requests\App\Auth\RegisterRequest (withValidator). Invite detection
no longer sniffs a /invites/ redirect string — it resolves the invite id
directly; the invite registration test now uses a real invite.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7be46102c7
commit
53a5a8bf22
90 changed files with 3444 additions and 320 deletions
38
app/Actions/Account/CancelAccountSubscription.php
Normal file
38
app/Actions/Account/CancelAccountSubscription.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Account;
|
||||
|
||||
use App\Models\Account;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class CancelAccountSubscription
|
||||
{
|
||||
/**
|
||||
* Cancel the non-ended named Stripe subscription for an account before
|
||||
* local teardown.
|
||||
*
|
||||
* @return bool false when Stripe cancel fails (nothing local should be deleted)
|
||||
*/
|
||||
public static function execute(Account $account): bool
|
||||
{
|
||||
try {
|
||||
$subscription = $account->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/Actions/Auth/LogoutAndInvalidateSession.php
Normal file
34
app/Actions/Auth/LogoutAndInvalidateSession.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Auth;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LogoutAndInvalidateSession
|
||||
{
|
||||
/**
|
||||
* Log the user out while the row still exists, then invalidate the session.
|
||||
* SessionGuard cycles the remember token via save() — calling this after
|
||||
* $user->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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
84
app/Actions/Invite/AcceptInvite.php
Normal file
84
app/Actions/Invite/AcceptInvite.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Invite;
|
||||
|
||||
use App\Actions\Account\CancelAccountSubscription;
|
||||
use App\Enums\Invite\Result;
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AcceptInvite
|
||||
{
|
||||
public static function execute(User $user, Invite $invite): Result
|
||||
{
|
||||
if ($invite->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 shell. Drop it after
|
||||
// commit so Stripe cancel is not held inside the invite lock. Members
|
||||
// never own a non-empty account, so there is nothing else to tear down.
|
||||
if ($result === Result::Accepted && $previousAccountId) {
|
||||
$shell = Account::query()
|
||||
->whereKey($previousAccountId)
|
||||
->where('owner_id', $user->id)
|
||||
->whereDoesntHave('workspaces')
|
||||
->first();
|
||||
|
||||
if ($shell && CancelAccountSubscription::execute($shell)) {
|
||||
$shell->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
51
app/Actions/Invite/AttachInviteWorkspaces.php
Normal file
51
app/Actions/Invite/AttachInviteWorkspaces.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Invite;
|
||||
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AttachInviteWorkspaces
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Workspace> $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();
|
||||
}
|
||||
}
|
||||
38
app/Actions/Invite/DeclineInvite.php
Normal file
38
app/Actions/Invite/DeclineInvite.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Invite;
|
||||
|
||||
use App\Enums\Invite\Result;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeclineInvite
|
||||
{
|
||||
public static function execute(User $user, Invite $invite): Result
|
||||
{
|
||||
if ($invite->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,54 @@
|
|||
|
||||
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.
|
||||
if (
|
||||
$account
|
||||
&& $user->account_id === $account->id
|
||||
&& $user->id !== $account->owner_id
|
||||
) {
|
||||
$settlement = SettleStrandedMember::execute($user, $account);
|
||||
}
|
||||
});
|
||||
|
||||
$settlement->flush();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
app/Actions/Invite/ResolveInviteWorkspaces.php
Normal file
34
app/Actions/Invite/ResolveInviteWorkspaces.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Invite;
|
||||
|
||||
use App\Models\Invite;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ResolveInviteWorkspaces
|
||||
{
|
||||
/**
|
||||
* Workspaces listed on the invite that still exist on the invite's account.
|
||||
*
|
||||
* @return Collection<int, Workspace>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
96
app/Actions/Invite/SettleAfterInvite.php
Normal file
96
app/Actions/Invite/SettleAfterInvite.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Invite;
|
||||
|
||||
use App\Actions\Auth\LogoutAndInvalidateSession;
|
||||
use App\Actions\User\SettleStrandedMember;
|
||||
use App\Actions\User\StrandedSettlement;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SettleAfterInvite
|
||||
{
|
||||
/**
|
||||
* After accept/decline: clear stranded non-owners, pick a safe same-account
|
||||
* current workspace, and redirect without bouncing through calendar
|
||||
* middleware that would drop flash messages.
|
||||
*
|
||||
* Never point current_workspace at a membership on another account
|
||||
* (WorkspacePolicy requires account_id match).
|
||||
*/
|
||||
public static function execute(User $user): RedirectResponse
|
||||
{
|
||||
$user->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,
|
||||
]);
|
||||
}
|
||||
|
||||
$settlement = StrandedSettlement::none();
|
||||
|
||||
DB::transaction(function () use ($leavingAccount, $userId, &$settlement): void {
|
||||
// Serialize with workspace/member deletes on this account.
|
||||
Account::query()->whereKey($leavingAccount->id)->lockForUpdate()->first();
|
||||
|
||||
$stranded = User::query()->find($userId);
|
||||
|
||||
if ($stranded) {
|
||||
$settlement = SettleStrandedMember::execute($stranded, $leavingAccount);
|
||||
}
|
||||
});
|
||||
|
||||
$settlement->flush();
|
||||
|
||||
if (! User::query()->whereKey($userId)->exists()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$user->refresh();
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
39
app/Actions/Media/DeleteOrphanedMediaFiles.php
Normal file
39
app/Actions/Media/DeleteOrphanedMediaFiles.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Media;
|
||||
|
||||
use App\Models\Media;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DeleteOrphanedMediaFiles
|
||||
{
|
||||
/**
|
||||
* Delete storage objects for media paths that no longer have DB rows.
|
||||
*
|
||||
* @param iterable<int, Media|string|null> $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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
34
app/Actions/Media/DeleteWorkspaceMedia.php
Normal file
34
app/Actions/Media/DeleteWorkspaceMedia.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Media;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class DeleteWorkspaceMedia
|
||||
{
|
||||
/**
|
||||
* Delete workspace media rows and return their storage paths.
|
||||
*
|
||||
* Intended for use inside a DB transaction / lock. Call
|
||||
* DeleteOrphanedMediaFiles with the returned paths after commit so
|
||||
* filesystem I/O stays outside the lock.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function purgeRecords(Workspace $workspace): array
|
||||
{
|
||||
$query = Media::query()
|
||||
->where('mediable_type', Relation::getMorphAlias(Workspace::class))
|
||||
->where('mediable_id', $workspace->id);
|
||||
|
||||
/** @var list<string> $paths */
|
||||
$paths = $query->pluck('path')->all();
|
||||
$query->delete();
|
||||
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
110
app/Actions/User/DeleteUser.php
Normal file
110
app/Actions/User/DeleteUser.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Actions\Account\CancelAccountSubscription;
|
||||
use App\Actions\Auth\LogoutAndInvalidateSession;
|
||||
use App\Actions\Workspace\PurgeWorkspace;
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteUser
|
||||
{
|
||||
/**
|
||||
* Permanently delete the authenticated user and, when they own the
|
||||
* account, the shared account (after Stripe cancel).
|
||||
*
|
||||
* @return bool false when Stripe cancel failed — nothing local was deleted
|
||||
*/
|
||||
public static function execute(User $user, Request $request): bool
|
||||
{
|
||||
$account = $user->account;
|
||||
$isOwner = $user->isAccountOwner();
|
||||
|
||||
// Stripe cancel must succeed before any local teardown. Members own no
|
||||
// account (closed-account model), so only the owner's account matters.
|
||||
if ($isOwner && $account && ! CancelAccountSubscription::execute($account)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$settlement = DB::transaction(function () use ($user, $account, $isOwner): StrandedSettlement {
|
||||
$user->update(['current_workspace_id' => null]);
|
||||
|
||||
if ($isOwner && $account) {
|
||||
$settlement = self::deleteOwnedAccount($user, $account);
|
||||
} else {
|
||||
// Members must never delete shared-account workspaces (even ones
|
||||
// they created). They own nothing, so just detach from the shared ones.
|
||||
$user->workspaces()->detach();
|
||||
$settlement = StrandedSettlement::none();
|
||||
$user->update(['account_id' => null]);
|
||||
}
|
||||
|
||||
return $settlement->merge(new StrandedSettlement(
|
||||
mediaPaths: PurgeUserAccess::execute($user),
|
||||
));
|
||||
});
|
||||
|
||||
$settlement->flush();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the owned shared account: workspaces, invites, and delete its
|
||||
* members. Must run inside a DB transaction (locks the account row).
|
||||
*/
|
||||
private static function deleteOwnedAccount(User $owner, Account $account): StrandedSettlement
|
||||
{
|
||||
$locked = Account::query()->whereKey($account->id)->lockForUpdate()->first();
|
||||
|
||||
if (! $locked) {
|
||||
return StrandedSettlement::none();
|
||||
}
|
||||
|
||||
$settlement = StrandedSettlement::none();
|
||||
|
||||
Workspace::query()
|
||||
->where('account_id', $account->id)
|
||||
->get()
|
||||
->each(function (Workspace $workspace) use ($owner, &$settlement): void {
|
||||
ReassignCurrentWorkspace::awayFromWorkspace(
|
||||
$workspace,
|
||||
exceptUserId: $owner->id,
|
||||
);
|
||||
|
||||
$settlement = $settlement->merge(new StrandedSettlement(
|
||||
mediaPaths: PurgeWorkspace::execute($workspace),
|
||||
));
|
||||
});
|
||||
|
||||
$owner->workspaces()->detach();
|
||||
|
||||
Invite::query()->where('account_id', $account->id)->delete();
|
||||
|
||||
// Every non-owner member of this account is deleted with it.
|
||||
$settlement = $settlement->merge(
|
||||
SettleStrandedMember::forAccountMembers($account, exceptUserId: $owner->id),
|
||||
);
|
||||
|
||||
$owner->update(['account_id' => null]);
|
||||
|
||||
if (Account::query()->whereKey($account->id)->exists()) {
|
||||
$account->subscriptions()->delete();
|
||||
$account->delete();
|
||||
}
|
||||
|
||||
return $settlement;
|
||||
}
|
||||
}
|
||||
36
app/Actions/User/PurgeUserAccess.php
Normal file
36
app/Actions/User/PurgeUserAccess.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Laravel\Passport\Token;
|
||||
|
||||
class PurgeUserAccess
|
||||
{
|
||||
/**
|
||||
* Revoke Passport tokens and delete user media rows.
|
||||
*
|
||||
* @return list<string> 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<string> $mediaPaths */
|
||||
$mediaPaths = $userMediaQuery->pluck('path')->all();
|
||||
$userMediaQuery->delete();
|
||||
|
||||
$user->tokens()->each(function (Token $token): void {
|
||||
$token->revoke();
|
||||
$token->refreshToken?->revoke();
|
||||
});
|
||||
|
||||
return $mediaPaths;
|
||||
}
|
||||
}
|
||||
97
app/Actions/User/ReassignCurrentWorkspace.php
Normal file
97
app/Actions/User/ReassignCurrentWorkspace.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
class ReassignCurrentWorkspace
|
||||
{
|
||||
/**
|
||||
* If $user's current workspace is $workspace, point them at another
|
||||
* membership on the same account (or null). Never cross-account.
|
||||
*
|
||||
* When $attachOwnerFallback is true and the user owns the deleting
|
||||
* account, they may be attached as admin to another account workspace
|
||||
* they are not yet a member of (workspace-delete owner safety net).
|
||||
*/
|
||||
public static function forUserAwayFrom(
|
||||
User $user,
|
||||
Workspace $workspace,
|
||||
bool $attachOwnerFallback = false,
|
||||
?Account $account = null,
|
||||
): void {
|
||||
if ($user->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;
|
||||
}
|
||||
}
|
||||
75
app/Actions/User/SettleStrandedMember.php
Normal file
75
app/Actions/User/SettleStrandedMember.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
|
||||
class SettleStrandedMember
|
||||
{
|
||||
/**
|
||||
* After losing all memberships on a shared account, the invitee is deleted.
|
||||
* Members never own an account (closed-account model), so there is nothing
|
||||
* else to tear down.
|
||||
*
|
||||
* Callers must {@see StrandedSettlement::flush()} after releasing any lock.
|
||||
*/
|
||||
public static function execute(User $user, Account $leavingAccount): StrandedSettlement
|
||||
{
|
||||
if ($user->id === $leavingAccount->owner_id) {
|
||||
return StrandedSettlement::none();
|
||||
}
|
||||
|
||||
if ($user->workspaces()
|
||||
->where('workspaces.account_id', $leavingAccount->id)
|
||||
->exists()
|
||||
) {
|
||||
return StrandedSettlement::none();
|
||||
}
|
||||
|
||||
$mediaPaths = PurgeUserAccess::execute($user);
|
||||
|
||||
$user->workspaces()->detach();
|
||||
$user->update([
|
||||
'account_id' => null,
|
||||
'current_workspace_id' => null,
|
||||
]);
|
||||
$user->delete();
|
||||
|
||||
return new StrandedSettlement(mediaPaths: $mediaPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every non-owner member still pointing at $account. With
|
||||
* $onlyWithoutMemberships, only those with no remaining workspaces on it.
|
||||
*/
|
||||
public static function forAccountMembers(
|
||||
Account $account,
|
||||
?string $exceptUserId = null,
|
||||
bool $onlyWithoutMemberships = false,
|
||||
): StrandedSettlement {
|
||||
$settlement = StrandedSettlement::none();
|
||||
|
||||
User::query()
|
||||
->where('account_id', $account->id)
|
||||
->when(
|
||||
$exceptUserId,
|
||||
fn ($query) => $query->where('id', '!=', $exceptUserId),
|
||||
)
|
||||
->when(
|
||||
$onlyWithoutMemberships,
|
||||
fn ($query) => $query->whereDoesntHave(
|
||||
'workspaces',
|
||||
fn ($workspaces) => $workspaces->where('workspaces.account_id', $account->id),
|
||||
),
|
||||
)
|
||||
->get()
|
||||
->each(function (User $member) use ($account, &$settlement): void {
|
||||
$settlement = $settlement->merge(self::execute($member, $account));
|
||||
});
|
||||
|
||||
return $settlement;
|
||||
}
|
||||
}
|
||||
38
app/Actions/User/StrandedSettlement.php
Normal file
38
app/Actions/User/StrandedSettlement.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Actions\Media\DeleteOrphanedMediaFiles;
|
||||
|
||||
/**
|
||||
* Result of settling stranded members: media paths to remove from storage.
|
||||
* Flush outside any held lock so filesystem I/O is not serialized under it.
|
||||
*/
|
||||
final readonly class StrandedSettlement
|
||||
{
|
||||
/**
|
||||
* @param list<string> $mediaPaths
|
||||
*/
|
||||
public function __construct(
|
||||
public array $mediaPaths = [],
|
||||
) {}
|
||||
|
||||
public static function none(): self
|
||||
{
|
||||
return new self;
|
||||
}
|
||||
|
||||
public function merge(self $other): self
|
||||
{
|
||||
return new self(
|
||||
mediaPaths: [...$this->mediaPaths, ...$other->mediaPaths],
|
||||
);
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
DeleteOrphanedMediaFiles::execute($this->mediaPaths);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,34 +4,109 @@
|
|||
|
||||
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 (delete invitees who
|
||||
* lost their last membership on the account).
|
||||
*
|
||||
* 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();
|
||||
|
||||
$workspace->delete();
|
||||
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::forAccountMembers(
|
||||
$account,
|
||||
exceptUserId: $account->owner_id,
|
||||
onlyWithoutMemberships: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
$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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
app/Actions/Workspace/PurgeWorkspace.php
Normal file
27
app/Actions/Workspace/PurgeWorkspace.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Workspace;
|
||||
|
||||
use App\Actions\Media\DeleteWorkspaceMedia;
|
||||
use App\Models\Workspace;
|
||||
|
||||
class PurgeWorkspace
|
||||
{
|
||||
/**
|
||||
* Delete workspace media rows and the workspace itself.
|
||||
* Related posts/accounts/labels/etc. cascade via FK.
|
||||
*
|
||||
* Returns media storage paths for DeleteOrphanedMediaFiles after commit.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function execute(Workspace $workspace): array
|
||||
{
|
||||
$mediaPaths = DeleteWorkspaceMedia::purgeRecords($workspace);
|
||||
$workspace->delete();
|
||||
|
||||
return $mediaPaths;
|
||||
}
|
||||
}
|
||||
38
app/Enums/Invite/Result.php
Normal file
38
app/Enums/Invite/Result.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Invite;
|
||||
|
||||
enum Result
|
||||
{
|
||||
case WrongEmail;
|
||||
case Gone;
|
||||
case AlreadyAccepted;
|
||||
case AlreadyMember;
|
||||
case Accepted;
|
||||
case Declined;
|
||||
|
||||
public function flashBanner(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WrongEmail => __('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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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('/');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
|
||||
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
|
||||
use App\Http\Resources\App\WorkspaceMemberResource;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Brand\LogoAttacher;
|
||||
|
|
@ -53,7 +54,7 @@ public function index(Request $request): Response
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
$workspaces = $user->workspaces()
|
||||
$workspaces = $user->accountWorkspaces()
|
||||
->with('media')
|
||||
->withCount(['socialAccounts', 'posts'])
|
||||
->latest()
|
||||
|
|
@ -67,6 +68,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;
|
||||
}
|
||||
|
|
@ -87,6 +90,13 @@ public function create(Request $request): Response|RedirectResponse
|
|||
*/
|
||||
private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse
|
||||
{
|
||||
// An invited member joins exactly one account via the invite. Creating a
|
||||
// workspace on their empty invite-signup shell would leave it non-empty
|
||||
// and billable after accept abandons it — send them back to the invite.
|
||||
if (Invite::query()->where('email', $user->email)->whereNull('accepted_at')->exists()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! config('trypost.self_hosted')
|
||||
&& $user->ownedWorkspacesCount() > 0
|
||||
&& ! $user->account?->hasActiveSubscription()) {
|
||||
|
|
@ -132,6 +142,8 @@ public function switch(Request $request, Workspace $workspace): RedirectResponse
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
if (! $user->belongsToWorkspace($workspace)) {
|
||||
abort(403);
|
||||
}
|
||||
|
|
@ -154,6 +166,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 +258,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();
|
||||
|
||||
return redirect()->route('app.workspaces.index')
|
||||
// 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'));
|
||||
}
|
||||
|
||||
// Fallback workspace already set — back into the app, not the picker.
|
||||
return redirect()->route('app.calendar')
|
||||
->with('flash.success', __('workspaces.flash.deleted'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use App\Http\Requests\App\Invite\StoreWorkspaceInviteRequest;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -70,13 +71,16 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse
|
|||
|
||||
if ($existingInvite) {
|
||||
return back()->withErrors([
|
||||
'email' => 'An invite already exists for this email.',
|
||||
'email' => __('settings.members.errors.invite_exists'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($workspace->members()->where('email', $request->email)->exists()) {
|
||||
// Accounts are closed: a user always belongs to exactly one account.
|
||||
// Block invites to an email already registered, or two accounts would
|
||||
// hold the same person. Employees use a dedicated work email instead.
|
||||
if (User::query()->where('email', $request->email)->exists()) {
|
||||
return back()->withErrors([
|
||||
'email' => 'This user is already a member of the workspace.',
|
||||
'email' => __('settings.members.errors.email_belongs_to_account'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
$result = AcceptInvite::execute($request->user(), $invite);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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();
|
||||
$result = DeclineInvite::execute($request->user(), $invite);
|
||||
|
||||
// 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');
|
||||
|
||||
return redirect()->route('app.calendar');
|
||||
return SettleAfterInvite::flashAndRedirect(
|
||||
$request->user(),
|
||||
$result->flashBanner(),
|
||||
$result->flashStyle(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,11 @@
|
|||
use App\Actions\User\CreateUser;
|
||||
use App\Http\Controllers\Auth\Concerns\PreservesUtmParameters;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Http\Requests\App\Auth\RegisterRequest;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
|
|
@ -27,26 +26,19 @@ public function create(Request $request): Response
|
|||
return Inertia::render('auth/Register', [
|
||||
'email' => $request->query('email'),
|
||||
'redirect' => $request->query('redirect'),
|
||||
'invite' => $request->query('invite'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(RegisterRequest $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/');
|
||||
|
||||
$utmParameters = $this->retrieveUtmParameters();
|
||||
|
||||
$user = CreateUser::execute([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => $request->password,
|
||||
'is_invite' => $isInviteRegistration,
|
||||
'name' => $request->validated('name'),
|
||||
'email' => $request->validated('email'),
|
||||
'password' => $request->validated('password'),
|
||||
'is_invite' => $request->isInviteRegistration(),
|
||||
'registration_ip' => $request->ip(),
|
||||
], $utmParameters);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ public function handle(Request $request, Closure $next): mixed
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
if ($inviteId = $request->query('invite') ?? $request->session()->get('pending_invite_id')) {
|
||||
// `query` covers the GET form; `input` covers the invite field posted
|
||||
// with the registration form (a hidden input, not a query param).
|
||||
if ($inviteId = $request->query('invite') ?? $request->input('invite') ?? $request->session()->get('pending_invite_id')) {
|
||||
$request->session()->put('pending_invite_id', $inviteId);
|
||||
|
||||
return $next($request);
|
||||
|
|
|
|||
73
app/Http/Requests/App/Auth/RegisterRequest.php
Normal file
73
app/Http/Requests/App/Auth/RegisterRequest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Auth;
|
||||
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class RegisterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', Rules\Password::defaults()],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A valid invite binds registration to the invited email — never a
|
||||
* different one. Non-UUID invite params are self-hosted gate placeholders
|
||||
* and are ignored.
|
||||
*/
|
||||
public function invite(): ?Invite
|
||||
{
|
||||
$inviteId = (string) $this->input('invite', '');
|
||||
|
||||
if ($inviteId === '' || ! Str::isUuid($inviteId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Invite::query()->find($inviteId);
|
||||
}
|
||||
|
||||
public function isInviteRegistration(): bool
|
||||
{
|
||||
return $this->invite() !== null;
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$invite = $this->invite();
|
||||
|
||||
if ($invite && $invite->email !== $this->input('email')) {
|
||||
$validator->errors()->add(
|
||||
'email',
|
||||
__('settings.members.flash.wrong_email'),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Workspace, $this>
|
||||
*/
|
||||
public function accountWorkspaces(): BelongsToMany
|
||||
{
|
||||
return $this->workspaces()
|
||||
->where('workspaces.account_id', $this->account_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of workspaces the user owns.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@
|
|||
'login_prompt' => 'سجّل الدخول أو أنشئ حسابًا لقبول هذه الدعوة.',
|
||||
'log_in' => 'تسجيل الدخول',
|
||||
'create_account' => 'إنشاء حساب',
|
||||
'expired_title' => 'هذه الدعوة لم تعد صالحة',
|
||||
'expired_description' => 'تم حذف مساحة العمل المرتبطة بهذه الدعوة. اطلب دعوة جديدة من مالك الحساب إذا كنت لا تزال بحاجة إلى الوصول.',
|
||||
'expired_action' => 'الانتقال إلى الصفحة الرئيسية',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'مشاهد',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'يوجد بالفعل دعوة لهذا البريد الإلكتروني.',
|
||||
'email_belongs_to_account' => 'هذا البريد الإلكتروني ينتمي بالفعل إلى حساب TryPost آخر. اطلب بريد عمل مخصص بدلاً من ذلك.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'تم إرسال الدعوة بنجاح!',
|
||||
'invite_deleted' => 'تم حذف الدعوة.',
|
||||
|
|
@ -283,6 +302,7 @@
|
|||
'wrong_email' => 'هذه الدعوة مخصصة لبريد إلكتروني مختلف.',
|
||||
'already_member' => 'أنت عضو بالفعل في مساحة العمل هذه.',
|
||||
'invite_accepted' => 'مرحبًا! أنت الآن عضو في مساحة العمل.',
|
||||
'invite_workspace_gone' => 'هذه الدعوة لم تعد صالحة لأن مساحة العمل حُذفت.',
|
||||
'invite_declined' => 'تم رفض الدعوة.',
|
||||
],
|
||||
],
|
||||
|
|
@ -310,6 +330,7 @@
|
|||
'workspace_updated' => 'تم تحديث الإعدادات بنجاح!',
|
||||
'photo_updated' => 'تم تحديث الصورة بنجاح!',
|
||||
'photo_deleted' => 'تمت إزالة الصورة بنجاح!',
|
||||
'delete_failed_billing' => 'تعذّر إلغاء اشتراكك لدى مزود الفوترة. لم يُحذف أي شيء. يُرجى المحاولة مرة أخرى أو التواصل مع الدعم.',
|
||||
'logo_updated' => 'تم رفع الشعار بنجاح!',
|
||||
'logo_deleted' => 'تمت إزالة الشعار بنجاح!',
|
||||
'notifications_updated' => 'تم تحديث تفضيلات الإشعارات!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -277,6 +291,11 @@
|
|||
'viewer' => 'Betrachter',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Für diese E-Mail existiert bereits eine Einladung.',
|
||||
'email_belongs_to_account' => 'Diese E-Mail gehört bereits zu einem anderen TryPost-Konto. Bitte eine dedizierte Arbeits-E-Mail verwenden.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Einladung erfolgreich gesendet!',
|
||||
'invite_deleted' => 'Einladung gelöscht.',
|
||||
|
|
@ -285,6 +304,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 +332,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!',
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@
|
|||
'login_prompt' => 'Συνδεθείτε ή δημιουργήστε λογαριασμό για να αποδεχτείτε αυτή την πρόσκληση.',
|
||||
'log_in' => 'Σύνδεση',
|
||||
'create_account' => 'Δημιουργία λογαριασμού',
|
||||
'expired_title' => 'Αυτή η πρόσκληση δεν ισχύει πλέον',
|
||||
'expired_description' => 'Το workspace αυτής της πρόσκλησης διαγράφηκε. Ζητήστε νέα πρόσκληση από τον κάτοχο του λογαριασμού αν χρειάζεστε ακόμα πρόσβαση.',
|
||||
'expired_action' => 'Αρχική σελίδα',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Θεατής',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Υπάρχει ήδη πρόσκληση για αυτό το email.',
|
||||
'email_belongs_to_account' => 'Αυτό το email ανήκει ήδη σε άλλο λογαριασμό TryPost. Ζητήστε ένα ειδικό email εργασίας.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Η πρόσκληση στάλθηκε με επιτυχία!',
|
||||
'invite_deleted' => 'Η πρόσκληση διαγράφηκε.',
|
||||
|
|
@ -283,6 +302,7 @@
|
|||
'wrong_email' => 'Αυτή η πρόσκληση αφορά διαφορετική διεύθυνση email.',
|
||||
'already_member' => 'Είστε ήδη μέλος αυτού του workspace.',
|
||||
'invite_accepted' => 'Καλώς ήρθατε! Είστε πλέον μέλος του workspace.',
|
||||
'invite_workspace_gone' => 'Αυτή η πρόσκληση δεν ισχύει πλέον επειδή το workspace διαγράφηκε.',
|
||||
'invite_declined' => 'Η πρόσκληση απορρίφθηκε.',
|
||||
],
|
||||
],
|
||||
|
|
@ -310,6 +330,7 @@
|
|||
'workspace_updated' => 'Οι ρυθμίσεις ενημερώθηκαν με επιτυχία!',
|
||||
'photo_updated' => 'Η φωτογραφία ενημερώθηκε με επιτυχία!',
|
||||
'photo_deleted' => 'Η φωτογραφία αφαιρέθηκε με επιτυχία!',
|
||||
'delete_failed_billing' => 'Δεν μπορέσαμε να ακυρώσουμε τη συνδρομή σας στον πάροχο χρέωσης. Δεν διαγράφηκε τίποτα. Δοκιμάστε ξανά ή επικοινωνήστε με την υποστήριξη.',
|
||||
'logo_updated' => 'Το λογότυπο μεταφορτώθηκε με επιτυχία!',
|
||||
'logo_deleted' => 'Το λογότυπο αφαιρέθηκε με επιτυχία!',
|
||||
'notifications_updated' => 'Οι προτιμήσεις ειδοποιήσεων ενημερώθηκαν!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Viewer',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'An invite already exists for this email.',
|
||||
'email_belongs_to_account' => 'This email already belongs to another TryPost account. Ask for a dedicated work email instead.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Invite sent successfully!',
|
||||
'invite_deleted' => 'Invite deleted.',
|
||||
|
|
@ -283,6 +302,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 +330,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!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Espectador',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Ya existe una invitación para este correo.',
|
||||
'email_belongs_to_account' => 'Este correo ya pertenece a otra cuenta de TryPost. Pide un correo de trabajo dedicado.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => '¡Invitación enviada correctamente!',
|
||||
'invite_deleted' => 'Invitación eliminada.',
|
||||
|
|
@ -283,6 +302,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 +330,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!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Lecteur',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Une invitation existe déjà pour cet e-mail.',
|
||||
'email_belongs_to_account' => 'Cet e-mail appartient déjà à un autre compte TryPost. Demandez un e-mail professionnel dédié.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Invitation envoyée avec succès !',
|
||||
'invite_deleted' => 'Invitation supprimée.',
|
||||
|
|
@ -283,6 +302,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 +330,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 !',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Visualizzatore',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Esiste già un invito per questa email.',
|
||||
'email_belongs_to_account' => 'Questa email appartiene già a un altro account TryPost. Chiedi un’email di lavoro dedicata.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Invito inviato con successo!',
|
||||
'invite_deleted' => 'Invito eliminato.',
|
||||
|
|
@ -283,6 +302,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 +330,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!',
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@
|
|||
'login_prompt' => 'この招待を承諾するには、ログインまたはアカウントを作成してください。',
|
||||
'log_in' => 'ログイン',
|
||||
'create_account' => 'アカウントを作成',
|
||||
'expired_title' => 'この招待は無効です',
|
||||
'expired_description' => 'この招待のワークスペースは削除されました。引き続きアクセスが必要な場合は、アカウント所有者に新しい招待を依頼してください。',
|
||||
'expired_action' => 'ホームへ',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => '閲覧者',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'このメールアドレスには既に招待が存在します。',
|
||||
'email_belongs_to_account' => 'このメールアドレスは既に別の TryPost アカウントに属しています。専用の仕事用メールアドレスをご利用ください。',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => '招待を正常に送信しました!',
|
||||
'invite_deleted' => '招待を削除しました。',
|
||||
|
|
@ -283,6 +302,7 @@
|
|||
'wrong_email' => 'この招待は別のメールアドレス宛てです。',
|
||||
'already_member' => 'あなたはすでにこのワークスペースのメンバーです。',
|
||||
'invite_accepted' => 'ようこそ!これでワークスペースのメンバーになりました。',
|
||||
'invite_workspace_gone' => 'ワークスペースが削除されたため、この招待は無効です。',
|
||||
'invite_declined' => '招待を辞退しました。',
|
||||
],
|
||||
],
|
||||
|
|
@ -310,6 +330,7 @@
|
|||
'workspace_updated' => '設定を正常に更新しました!',
|
||||
'photo_updated' => '写真を正常に更新しました!',
|
||||
'photo_deleted' => '写真を正常に削除しました!',
|
||||
'delete_failed_billing' => '請求プロバイダーでのサブスクリプションの解約に失敗しました。何も削除されていません。もう一度お試しいただくか、サポートまでご連絡ください。',
|
||||
'logo_updated' => 'ロゴを正常にアップロードしました!',
|
||||
'logo_deleted' => 'ロゴを正常に削除しました!',
|
||||
'notifications_updated' => '通知設定を更新しました!',
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@
|
|||
'login_prompt' => '이 초대를 수락하려면 로그인하거나 계정을 만드세요.',
|
||||
'log_in' => '로그인',
|
||||
'create_account' => '계정 만들기',
|
||||
'expired_title' => '이 초대는 더 이상 유효하지 않습니다',
|
||||
'expired_description' => '이 초대의 워크스페이스가 삭제되었습니다. 계속 접근이 필요하면 계정 소유자에게 새 초대를 요청하세요.',
|
||||
'expired_action' => '홈으로',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => '뷰어',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => '이 이메일에 대한 초대가 이미 존재합니다.',
|
||||
'email_belongs_to_account' => '이 이메일은 이미 다른 TryPost 계정에 속해 있습니다. 전용 업무용 이메일을 사용해 주세요.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => '초대가 성공적으로 전송되었습니다!',
|
||||
'invite_deleted' => '초대가 삭제되었습니다.',
|
||||
|
|
@ -283,6 +302,7 @@
|
|||
'wrong_email' => '이 초대는 다른 이메일 주소를 위한 것입니다.',
|
||||
'already_member' => '이미 이 워크스페이스의 멤버입니다.',
|
||||
'invite_accepted' => '환영합니다! 이제 워크스페이스의 멤버입니다.',
|
||||
'invite_workspace_gone' => '워크스페이스가 삭제되어 이 초대는 더 이상 유효하지 않습니다.',
|
||||
'invite_declined' => '초대를 거절했습니다.',
|
||||
],
|
||||
],
|
||||
|
|
@ -310,6 +330,7 @@
|
|||
'workspace_updated' => '설정이 성공적으로 업데이트되었습니다!',
|
||||
'photo_updated' => '사진이 성공적으로 업데이트되었습니다!',
|
||||
'photo_deleted' => '사진이 성공적으로 제거되었습니다!',
|
||||
'delete_failed_billing' => '결제 제공업체에서 구독을 취소하지 못했습니다. 아무것도 삭제되지 않았습니다. 다시 시도하거나 지원팀에 문의하세요.',
|
||||
'logo_updated' => '로고가 성공적으로 업로드되었습니다!',
|
||||
'logo_deleted' => '로고가 성공적으로 제거되었습니다!',
|
||||
'notifications_updated' => '알림 설정이 업데이트되었습니다!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Kijker',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Er bestaat al een uitnodiging voor dit e-mailadres.',
|
||||
'email_belongs_to_account' => 'Dit e-mailadres behoort al tot een ander TryPost-account. Vraag om een speciaal werk-e-mailadres.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Uitnodiging succesvol verstuurd!',
|
||||
'invite_deleted' => 'Uitnodiging verwijderd.',
|
||||
|
|
@ -283,6 +302,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 +330,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!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Przeglądający',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Zaproszenie dla tego adresu e-mail już istnieje.',
|
||||
'email_belongs_to_account' => 'Ten adres e-mail należy już do innego konta TryPost. Poproś o dedykowany służbowy adres e-mail.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Zaproszenie zostało pomyślnie wysłane!',
|
||||
'invite_deleted' => 'Zaproszenie usunięte.',
|
||||
|
|
@ -283,6 +302,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 +330,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!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Visualizador',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Já existe um convite para este e-mail.',
|
||||
'email_belongs_to_account' => 'Este e-mail já pertence a outra conta TryPost. Peça um e-mail de trabalho dedicado.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Convite enviado com sucesso!',
|
||||
'invite_deleted' => 'Convite excluído.',
|
||||
|
|
@ -283,6 +302,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 +330,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!',
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@
|
|||
'login_prompt' => 'Войдите или создайте аккаунт, чтобы принять приглашение.',
|
||||
'log_in' => 'Войти',
|
||||
'create_account' => 'Создать аккаунт',
|
||||
'expired_title' => 'Это приглашение больше недействительно',
|
||||
'expired_description' => 'Рабочее пространство для этого приглашения было удалено. Попросите владельца аккаунта прислать новое приглашение, если доступ всё ещё нужен.',
|
||||
'expired_action' => 'На главную',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => 'Наблюдатель',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Для этого e-mail уже существует приглашение.',
|
||||
'email_belongs_to_account' => 'Этот e-mail уже принадлежит другому аккаунту TryPost. Попросите выделенный рабочий e-mail.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Приглашение успешно отправлено!',
|
||||
'invite_deleted' => 'Приглашение удалено.',
|
||||
|
|
@ -283,6 +302,7 @@
|
|||
'wrong_email' => 'Это приглашение для другого адреса email.',
|
||||
'already_member' => 'Вы уже являетесь участником этого рабочего пространства.',
|
||||
'invite_accepted' => 'Добро пожаловать! Теперь вы участник рабочего пространства.',
|
||||
'invite_workspace_gone' => 'Это приглашение больше недействительно, потому что рабочее пространство было удалено.',
|
||||
'invite_declined' => 'Приглашение отклонено.',
|
||||
],
|
||||
],
|
||||
|
|
@ -310,6 +330,7 @@
|
|||
'workspace_updated' => 'Настройки успешно обновлены!',
|
||||
'photo_updated' => 'Фото успешно обновлено!',
|
||||
'photo_deleted' => 'Фото успешно удалено!',
|
||||
'delete_failed_billing' => 'Не удалось отменить подписку у платёжного провайдера. Ничего не было удалено. Попробуйте снова или обратитесь в поддержку.',
|
||||
'logo_updated' => 'Логотип успешно загружен!',
|
||||
'logo_deleted' => 'Логотип успешно удалён!',
|
||||
'notifications_updated' => 'Настройки уведомлений обновлены!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -277,6 +291,11 @@
|
|||
'viewer' => 'İzleyici',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => 'Bu e-posta için zaten bir davet var.',
|
||||
'email_belongs_to_account' => 'Bu e-posta zaten başka bir TryPost hesabına ait. Bunun yerine özel bir iş e-postası isteyin.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => 'Davet başarıyla gönderildi!',
|
||||
'invite_deleted' => 'Davet silindi.',
|
||||
|
|
@ -285,6 +304,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 +332,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!',
|
||||
|
|
|
|||
|
|
@ -136,6 +136,9 @@
|
|||
'login_prompt' => '登录或创建账户以接受此邀请。',
|
||||
'log_in' => '登录',
|
||||
'create_account' => '创建账户',
|
||||
'expired_title' => '此邀请已失效',
|
||||
'expired_description' => '该邀请对应的工作区已被删除。如仍需访问,请向账户所有者索取新邀请。',
|
||||
'expired_action' => '返回首页',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
@ -275,6 +289,11 @@
|
|||
'viewer' => '查看者',
|
||||
],
|
||||
|
||||
'errors' => [
|
||||
'invite_exists' => '该邮箱已有邀请。',
|
||||
'email_belongs_to_account' => '该邮箱已属于另一个 TryPost 账户。请改用专用工作邮箱。',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'invite_sent' => '邀请发送成功!',
|
||||
'invite_deleted' => '邀请已删除。',
|
||||
|
|
@ -283,6 +302,7 @@
|
|||
'wrong_email' => '此邀请是发给另一个邮箱地址的。',
|
||||
'already_member' => '你已经是此工作区的成员。',
|
||||
'invite_accepted' => '欢迎!你现在是此工作区的成员了。',
|
||||
'invite_workspace_gone' => '该工作区已删除,此邀请已失效。',
|
||||
'invite_declined' => '邀请已拒绝。',
|
||||
],
|
||||
],
|
||||
|
|
@ -310,6 +330,7 @@
|
|||
'workspace_updated' => '设置更新成功!',
|
||||
'photo_updated' => '头像更新成功!',
|
||||
'photo_deleted' => '头像移除成功!',
|
||||
'delete_failed_billing' => '无法在账单提供商处取消订阅。未删除任何内容。请重试或联系支持。',
|
||||
'logo_updated' => '徽标上传成功!',
|
||||
'logo_deleted' => '徽标移除成功!',
|
||||
'notifications_updated' => '通知偏好已更新!',
|
||||
|
|
|
|||
122
resources/js/components/settings/DeleteWorkspace.vue
Normal file
122
resources/js/components/settings/DeleteWorkspace.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { trans, transChoice } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { edit as editAuthentication } from '@/routes/app/authentication';
|
||||
import { index as billingIndex } from '@/routes/app/billing';
|
||||
import { destroy as destroyWorkspace } from '@/routes/app/workspaces';
|
||||
|
||||
const props = defineProps<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
isOnlyWorkspace: boolean;
|
||||
otherMemberCount: number;
|
||||
}>();
|
||||
|
||||
const page = usePage();
|
||||
const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
|
||||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const membersWarning = computed(() => {
|
||||
if (props.otherMemberCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return transChoice(
|
||||
'settings.workspace.delete_members_warning',
|
||||
props.otherMemberCount,
|
||||
{ count: String(props.otherMemberCount) },
|
||||
);
|
||||
});
|
||||
|
||||
const warningMessage = computed(() => {
|
||||
if (props.isOnlyWorkspace) {
|
||||
return trans('settings.workspace.delete_only_description');
|
||||
}
|
||||
|
||||
const base = isSelfHosted.value
|
||||
? trans('settings.workspace.delete_description_self_hosted')
|
||||
: trans('settings.workspace.delete_description');
|
||||
|
||||
return membersWarning.value ? `${base} ${membersWarning.value}` : base;
|
||||
});
|
||||
|
||||
const confirmDescription = computed(() => {
|
||||
const base = isSelfHosted.value
|
||||
? trans('settings.workspace.delete_confirm_description_self_hosted')
|
||||
: trans('settings.workspace.delete_confirm_description');
|
||||
|
||||
return membersWarning.value ? `${base} ${membersWarning.value}` : base;
|
||||
});
|
||||
|
||||
const openDeleteModal = () => {
|
||||
deleteModal.value?.open({
|
||||
url: destroyWorkspace.url(props.workspace.id),
|
||||
confirmText: props.workspace.name,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6" dusk="workspace-danger-zone">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.workspace.delete_title')"
|
||||
:description="$t('settings.workspace.danger_description')"
|
||||
/>
|
||||
|
||||
<div class="space-y-4 rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
|
||||
<div class="relative space-y-0.5 text-rose-700">
|
||||
<p class="font-bold">{{ $t('settings.workspace.delete_warning') }}</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ warningMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="!isOnlyWorkspace"
|
||||
variant="destructive"
|
||||
dusk="workspace-delete"
|
||||
@click="openDeleteModal"
|
||||
>
|
||||
{{ $t('settings.workspace.delete_action') }}
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="outline"
|
||||
as-child
|
||||
dusk="workspace-delete-billing-link"
|
||||
>
|
||||
<Link :href="billingIndex()">
|
||||
{{ $t('settings.workspace.delete_go_to_billing') }}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
as-child
|
||||
dusk="workspace-delete-account-link"
|
||||
>
|
||||
<Link :href="editAuthentication()">
|
||||
{{ $t('settings.workspace.delete_go_to_delete_account') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="deleteModal"
|
||||
:title="$t('settings.workspace.delete_confirm_title')"
|
||||
:description="confirmDescription"
|
||||
:action="$t('settings.workspace.delete_action')"
|
||||
:cancel="$t('settings.workspace.delete_cancel')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -67,5 +73,15 @@ defineProps<{
|
|||
<Button :disabled="processing">{{ $t('settings.workspace.save') }}</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<template v-if="canManageBilling">
|
||||
<Separator />
|
||||
|
||||
<DeleteWorkspace
|
||||
:workspace="workspace"
|
||||
:is-only-workspace="isOnlyWorkspace"
|
||||
:other-member-count="otherMemberCount"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { login, register } from '@/routes';
|
||||
import { accept, decline } from '@/routes/app/invites';
|
||||
import { home } from '@/routes/app';
|
||||
import { accept, decline, show } from '@/routes/app/invites';
|
||||
import { type SharedData } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
expired: boolean;
|
||||
invite: {
|
||||
id: string;
|
||||
email: string;
|
||||
|
|
@ -19,19 +22,35 @@ const props = defineProps<{
|
|||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
const page = usePage<SharedData>();
|
||||
const user = computed(() => page.props.auth?.user);
|
||||
const isLoggedIn = computed(() => !!user.value);
|
||||
|
||||
const inviteUrl = computed(() => `/invites/${props.invite.id}`);
|
||||
const inviteUrl = computed(() =>
|
||||
props.invite ? show.url(props.invite.id) : home.url(),
|
||||
);
|
||||
|
||||
const title = computed(() =>
|
||||
props.expired
|
||||
? trans('auth.accept_invite.expired_title')
|
||||
: trans('auth.accept_invite.title'),
|
||||
);
|
||||
|
||||
const description = computed(() =>
|
||||
props.expired
|
||||
? trans('auth.accept_invite.expired_description')
|
||||
: trans('auth.accept_invite.description', {
|
||||
workspace: props.invite?.workspace.name ?? '',
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -42,7 +61,7 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`);
|
|||
<div class="w-full max-w-md">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Link href="/" class="flex flex-col items-center gap-2 font-medium">
|
||||
<Link :href="home()" class="flex flex-col items-center gap-2 font-medium">
|
||||
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-8 w-auto" />
|
||||
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-8 w-auto" />
|
||||
</Link>
|
||||
|
|
@ -50,54 +69,92 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`);
|
|||
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-xl">{{ $t('auth.accept_invite.title') }}</CardTitle>
|
||||
<CardTitle class="text-xl">
|
||||
{{ title }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ $t('auth.accept_invite.description', { workspace: invite.workspace?.name ?? invite.account.name }) }}
|
||||
{{ description }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="rounded-lg bg-muted p-4 space-y-2">
|
||||
<div v-if="invite.workspace" class="flex justify-between gap-3 text-sm">
|
||||
<span class="shrink-0 text-muted-foreground">{{ $t('auth.accept_invite.workspace') }}</span>
|
||||
<span class="min-w-0 truncate font-medium">{{ invite.workspace.name }}</span>
|
||||
<template v-if="!expired && invite">
|
||||
<div class="space-y-2 rounded-lg bg-muted p-4">
|
||||
<div class="flex justify-between gap-3 text-sm">
|
||||
<span class="shrink-0 text-muted-foreground">{{
|
||||
$t('auth.accept_invite.workspace')
|
||||
}}</span>
|
||||
<span class="min-w-0 truncate font-medium">{{
|
||||
invite.workspace.name
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3 text-sm">
|
||||
<span class="shrink-0 text-muted-foreground">{{
|
||||
$t('auth.accept_invite.your_role')
|
||||
}}</span>
|
||||
<span class="min-w-0 truncate font-medium">{{
|
||||
invite.role.label
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3 text-sm">
|
||||
<span class="shrink-0 text-muted-foreground">{{
|
||||
$t('auth.accept_invite.email')
|
||||
}}</span>
|
||||
<span class="min-w-0 truncate font-medium">{{ invite.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3 text-sm">
|
||||
<span class="shrink-0 text-muted-foreground">{{ $t('auth.accept_invite.your_role') }}</span>
|
||||
<span class="min-w-0 truncate font-medium">{{ invite.role.label }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3 text-sm">
|
||||
<span class="shrink-0 text-muted-foreground">{{ $t('auth.accept_invite.email') }}</span>
|
||||
<span class="min-w-0 truncate font-medium">{{ invite.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User is logged in - show Accept/Decline -->
|
||||
<div v-if="isLoggedIn" class="flex flex-col gap-3">
|
||||
<Button as-child size="lg" class="w-full">
|
||||
<Link :href="accept.url(invite.id)" method="post">
|
||||
{{ $t('auth.accept_invite.accept') }}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline" size="lg" class="w-full">
|
||||
<Link :href="decline.url(invite.id)" method="post">
|
||||
{{ $t('auth.accept_invite.decline') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<!-- User is logged in - show Accept/Decline -->
|
||||
<div v-if="isLoggedIn" class="flex flex-col gap-3">
|
||||
<Button as-child size="lg" class="w-full">
|
||||
<Link :href="accept.url(invite.id)" method="post">
|
||||
{{ $t('auth.accept_invite.accept') }}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline" size="lg" class="w-full">
|
||||
<Link :href="decline.url(invite.id)" method="post">
|
||||
{{ $t('auth.accept_invite.decline') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- User is not logged in - show Login/Register -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
{{ $t('auth.accept_invite.login_prompt') }}
|
||||
</p>
|
||||
<Button as-child size="lg" class="w-full">
|
||||
<Link
|
||||
:href="
|
||||
login({
|
||||
query: { redirect: inviteUrl, email: invite.email },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ $t('auth.accept_invite.log_in') }}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline" size="lg" class="w-full">
|
||||
<Link
|
||||
:href="
|
||||
register({
|
||||
query: {
|
||||
redirect: inviteUrl,
|
||||
email: invite.email,
|
||||
invite: invite.id,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ $t('auth.accept_invite.create_account') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- User is not logged in - show Login/Register -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
{{ $t('auth.accept_invite.login_prompt') }}
|
||||
</p>
|
||||
<Button as-child size="lg" class="w-full">
|
||||
<Link :href="login({ query: { redirect: inviteUrl, email: invite.email } })">
|
||||
{{ $t('auth.accept_invite.log_in') }}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline" size="lg" class="w-full">
|
||||
<Link :href="register({ query: { redirect: inviteUrl, email: invite.email, invite: invite.id } })">
|
||||
{{ $t('auth.accept_invite.create_account') }}
|
||||
<Link :href="home()">
|
||||
{{ $t('auth.accept_invite.expired_action') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -106,4 +163,4 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`);
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { store } from '@/routes/register';
|
|||
defineProps<{
|
||||
email?: string | null;
|
||||
redirect?: string | null;
|
||||
invite?: string | null;
|
||||
}>();
|
||||
|
||||
const showPassword = ref(false);
|
||||
|
|
@ -72,6 +73,7 @@ const emailFormVisible = computed(() => !hasSocial.value || showEmailForm.value)
|
|||
class="flex flex-col gap-6"
|
||||
>
|
||||
<input v-if="redirect" type="hidden" name="redirect" :value="redirect" />
|
||||
<input v-if="invite" type="hidden" name="invite" :value="invite" />
|
||||
|
||||
<div
|
||||
v-if="hasSocial && showEmailForm"
|
||||
|
|
@ -110,6 +112,9 @@ const emailFormVisible = computed(() => !hasSocial.value || showEmailForm.value)
|
|||
name="email"
|
||||
placeholder="email@example.com"
|
||||
:default-value="email ?? ''"
|
||||
:readonly="Boolean(invite)"
|
||||
:aria-readonly="Boolean(invite)"
|
||||
:class="{ 'pointer-events-none opacity-60': invite }"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ interface Workspace {
|
|||
|
||||
defineProps<{
|
||||
workspace: Workspace;
|
||||
isOnlyWorkspace: boolean;
|
||||
otherMemberCount: number;
|
||||
}>();
|
||||
|
||||
const tabs = computed(() => [
|
||||
|
|
@ -46,7 +48,11 @@ const tabs = computed(() => [
|
|||
|
||||
<SettingsTabsNav :tabs="tabs" active="workspace" />
|
||||
|
||||
<WorkspaceTab :workspace="workspace" />
|
||||
<WorkspaceTab
|
||||
:workspace="workspace"
|
||||
:is-only-workspace="isOnlyWorkspace"
|
||||
:other-member-count="otherMemberCount"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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<Props>();
|
||||
|
||||
const { canCreateWorkspace } = useWorkspaceRole();
|
||||
|
||||
const switchToWorkspace = (workspace: Workspace) => {
|
||||
router.post(switchMethod.url(workspace.id), {}, {
|
||||
preserveState: false,
|
||||
|
|
@ -63,7 +66,7 @@ const switchToWorkspace = (workspace: Workspace) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Link :href="createWorkspace.url()">
|
||||
<Link v-if="canCreateWorkspace" :href="createWorkspace.url()">
|
||||
<Button variant="outline" class="w-full">
|
||||
{{ $t('workspaces.create.submit') }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -172,7 +200,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 +219,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 +230,134 @@
|
|||
// 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', function () {
|
||||
[
|
||||
'member' => $member,
|
||||
] = 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();
|
||||
});
|
||||
|
||||
test('decline of a dead invite deletes a stranded non-owner', function () {
|
||||
[
|
||||
'member' => $member,
|
||||
] = 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();
|
||||
});
|
||||
|
||||
test('decline invite requires authentication', function () {
|
||||
|
|
@ -228,7 +385,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 +406,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
|
||||
|
|
|
|||
58
tests/Feature/Actions/Invite/RemoveMemberTest.php
Normal file
58
tests/Feature/Actions/Invite/RemoveMemberTest.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Invite\RemoveMember;
|
||||
use App\Models\User;
|
||||
|
||||
test('remove member clears current workspace when it was the removed membership', function () {
|
||||
[
|
||||
'owner' => $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,
|
||||
'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();
|
||||
});
|
||||
|
||||
test('remove member prefers another workspace on the same account', function () {
|
||||
[
|
||||
'owner' => $owner,
|
||||
'member' => $member,
|
||||
'shared_workspaces' => [$sharedA, $sharedB],
|
||||
] = strandedMemberOnSharedAccount(
|
||||
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);
|
||||
});
|
||||
78
tests/Feature/Actions/Media/DeleteWorkspaceMediaTest.php
Normal file
78
tests/Feature/Actions/Media/DeleteWorkspaceMediaTest.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Media\DeleteOrphanedMediaFiles;
|
||||
use App\Actions\Media\DeleteWorkspaceMedia;
|
||||
use App\Models\Media;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
test('purgeRecords deletes workspace media rows and returns their paths', function () {
|
||||
Storage::fake();
|
||||
|
||||
$owner = User::factory()->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);
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Workspace\DeleteWorkspace;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
test('delete workspace does not lazy load account while reassigning current workspace', function () {
|
||||
Model::preventLazyLoading();
|
||||
|
||||
$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]);
|
||||
|
||||
$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();
|
||||
});
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Workspace\DeleteWorkspace;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
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;
|
||||
|
||||
test('delete workspace deletes stranded members', function () {
|
||||
[
|
||||
'member' => $member,
|
||||
'shared_workspaces' => [$workspace],
|
||||
] = strandedMemberOnSharedAccount(
|
||||
sharedWorkspaces: 2,
|
||||
attachMemberToAll: false,
|
||||
setMemberCurrent: true,
|
||||
);
|
||||
|
||||
DeleteWorkspace::execute($workspace);
|
||||
|
||||
expect(User::find($member->id))->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 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
65
tests/Feature/Auth/InviteRegistrationEmailTest.php
Normal file
65
tests/Feature/Auth/InviteRegistrationEmailTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$this->account = Account::factory()->create();
|
||||
$this->owner = User::factory()->create(['account_id' => $this->account->id]);
|
||||
$this->account->update(['owner_id' => $this->owner->id]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->owner->id,
|
||||
]);
|
||||
$this->invite = Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
});
|
||||
|
||||
test('register page receives the invite id', function () {
|
||||
$this->get(route('register', [
|
||||
'email' => 'invitee@example.com',
|
||||
'invite' => $this->invite->id,
|
||||
'redirect' => route('app.invites.show', $this->invite),
|
||||
]))
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->where('invite', $this->invite->id)
|
||||
->where('email', 'invitee@example.com')
|
||||
);
|
||||
});
|
||||
|
||||
test('invite registration rejects a different email than the invite', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Invitee',
|
||||
'email' => 'other@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'invite' => $this->invite->id,
|
||||
'redirect' => route('app.invites.show', $this->invite),
|
||||
])->assertSessionHasErrors('email');
|
||||
|
||||
expect(User::where('email', 'other@example.com')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('invite registration allows the invited email', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Invitee',
|
||||
'email' => 'invitee@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'invite' => $this->invite->id,
|
||||
'redirect' => route('app.invites.show', $this->invite),
|
||||
])->assertRedirect();
|
||||
|
||||
expect(User::where('email', 'invitee@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(fn () => config()->set('trypost.self_hosted', false));
|
||||
|
||||
|
|
@ -53,11 +56,26 @@
|
|||
});
|
||||
|
||||
test('new users registering via invite have verified email automatically', function () {
|
||||
$account = Account::factory()->create();
|
||||
$owner = User::factory()->create(['account_id' => $account->id]);
|
||||
$account->update(['owner_id' => $owner->id]);
|
||||
$workspace = Workspace::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'user_id' => $owner->id,
|
||||
]);
|
||||
$invite = Invite::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'invited_by' => $owner->id,
|
||||
'email' => 'test@example.com',
|
||||
'workspaces' => [$workspace->id],
|
||||
]);
|
||||
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'Password123!',
|
||||
'redirect' => '/invites/some-invite-id',
|
||||
'invite' => $invite->id,
|
||||
'redirect' => route('app.invites.show', $invite),
|
||||
]);
|
||||
|
||||
$user = User::where('email', 'test@example.com')->first();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,25 @@
|
|||
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();
|
||||
test('deleting account deletes members who belong to the shared account', function () {
|
||||
[
|
||||
'owner' => $owner,
|
||||
'member' => $member,
|
||||
'shared_workspaces' => [$workspace],
|
||||
] = strandedMemberOnSharedAccount(
|
||||
sharedWorkspaces: 1,
|
||||
setMemberCurrent: true,
|
||||
);
|
||||
$memberToken = $member->createToken('Member API')->token;
|
||||
|
||||
// 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);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// 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(AccessToken::find($memberToken->id)->revoked)->toBeTrue();
|
||||
});
|
||||
|
||||
test('user can upload profile photo', function () {
|
||||
|
|
@ -275,6 +252,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 +291,25 @@
|
|||
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,
|
||||
'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();
|
||||
});
|
||||
|
||||
test('owner deleting profile destroys the account and cascades', function (bool $selfHosted) {
|
||||
config()->set('trypost.self_hosted', $selfHosted);
|
||||
|
||||
|
|
@ -313,3 +329,162 @@
|
|||
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();
|
||||
$member->account?->delete();
|
||||
$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($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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -80,6 +80,19 @@
|
|||
Mail::assertQueued(WorkspaceInviteMail::class);
|
||||
});
|
||||
|
||||
test('store invite blocks an email that already belongs to a registered user', function () {
|
||||
User::factory()->create(['email' => 'existing@example.com']);
|
||||
|
||||
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
|
||||
'email' => 'existing@example.com',
|
||||
'role' => WorkspaceRole::Member->value,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('email');
|
||||
$this->assertDatabaseMissing('invites', ['email' => 'existing@example.com']);
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('store invite requires a role', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
|
||||
'email' => 'newmember@example.com',
|
||||
|
|
@ -193,6 +206,22 @@
|
|||
expect($this->workspace->hasMember($member))->toBeFalse();
|
||||
});
|
||||
|
||||
test('remove member deletes stranded members', function () {
|
||||
[
|
||||
'member' => $member,
|
||||
] = 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();
|
||||
});
|
||||
|
||||
test('remove member fails for owner', function () {
|
||||
$response = $this->actingAs($this->user)->delete(route('app.members.remove', $this->user));
|
||||
|
||||
|
|
|
|||
53
tests/Feature/WorkspaceInviteGuardTest.php
Normal file
53
tests/Feature/WorkspaceInviteGuardTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$this->account = Account::factory()->create();
|
||||
$this->owner = User::factory()->create(['account_id' => $this->account->id]);
|
||||
$this->account->update(['owner_id' => $this->owner->id]);
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->owner->id,
|
||||
]);
|
||||
|
||||
$this->invitee = User::factory()->create(['email' => 'invitee@example.com']);
|
||||
|
||||
Invite::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'invited_by' => $this->owner->id,
|
||||
'email' => 'invitee@example.com',
|
||||
'workspaces' => [$this->workspace->id],
|
||||
]);
|
||||
});
|
||||
|
||||
test('invitee cannot open workspace create form while invite is pending', function () {
|
||||
$this->actingAs($this->invitee)
|
||||
->get(route('app.workspaces.create'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('invitee cannot store a workspace while invite is pending', function () {
|
||||
$this->actingAs($this->invitee)
|
||||
->post(route('app.workspaces.store'), [
|
||||
'name' => 'Shell workspace',
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
expect($this->invitee->fresh()->account->workspaces()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('invitee can create a workspace after the invite is gone', function () {
|
||||
Invite::query()->delete();
|
||||
|
||||
$this->actingAs($this->invitee)
|
||||
->get(route('app.workspaces.create'))
|
||||
->assertOk();
|
||||
});
|
||||
|
|
@ -160,3 +160,64 @@ 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,
|
||||
* shared_workspaces: list<Workspace>
|
||||
* }
|
||||
*/
|
||||
function strandedMemberOnSharedAccount(
|
||||
int $sharedWorkspaces = 0,
|
||||
bool $attachMember = true,
|
||||
bool $attachMemberToAll = true,
|
||||
bool $setMemberCurrent = false,
|
||||
?User $owner = null,
|
||||
?string $memberEmail = null,
|
||||
): array {
|
||||
$owner ??= User::factory()->create();
|
||||
$member = User::factory()->create(array_filter([
|
||||
'email' => $memberEmail,
|
||||
]));
|
||||
|
||||
// Closed-account model: the member's empty signup shell is gone after
|
||||
// accepting the invite, so drop it here to match the real state.
|
||||
$member->account?->delete();
|
||||
|
||||
$shared = [];
|
||||
|
||||
for ($i = 0; $i < $sharedWorkspaces; $i++) {
|
||||
$workspace = Workspace::factory()->create([
|
||||
'account_id' => $owner->account_id,
|
||||
'user_id' => $owner->id,
|
||||
]);
|
||||
|
||||
$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(),
|
||||
'shared_workspaces' => $shared,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
55
tests/Unit/Actions/Account/CancelAccountSubscriptionTest.php
Normal file
55
tests/Unit/Actions/Account/CancelAccountSubscriptionTest.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Account\CancelAccountSubscription;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
test('cancel succeeds when there is no subscription', function () {
|
||||
$owner = User::factory()->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();
|
||||
});
|
||||
27
tests/Unit/Actions/Auth/LogoutAndInvalidateSessionTest.php
Normal file
27
tests/Unit/Actions/Auth/LogoutAndInvalidateSessionTest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Auth\LogoutAndInvalidateSession;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
test('logout invalidates the session and can reflash a banner', function () {
|
||||
$user = User::factory()->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');
|
||||
});
|
||||
120
tests/Unit/Actions/User/SettleStrandedMemberTest.php
Normal file
120
tests/Unit/Actions/User/SettleStrandedMemberTest.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\User\SettleStrandedMember;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\AccessToken;
|
||||
use App\Models\Account;
|
||||
use App\Models\Media;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
test('no-ops when the user still has a membership on the leaving account', function () {
|
||||
['owner' => $owner, 'member' => $member] = 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);
|
||||
});
|
||||
|
||||
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', function () {
|
||||
['owner' => $owner, 'member' => $member] = strandedMemberOnSharedAccount();
|
||||
|
||||
SettleStrandedMember::execute($member->fresh(), $owner->account);
|
||||
|
||||
expect(User::find($member->id))->toBeNull();
|
||||
});
|
||||
|
||||
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('forAccountMembers only deletes members without remaining account workspaces', function () {
|
||||
[
|
||||
'owner' => $owner,
|
||||
'member' => $stranded,
|
||||
'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::forAccountMembers(
|
||||
$owner->account,
|
||||
exceptUserId: $owner->id,
|
||||
onlyWithoutMemberships: true,
|
||||
)->flush();
|
||||
|
||||
expect(User::find($stranded->id))->toBeNull();
|
||||
expect(User::find($stillMember->id))->not->toBeNull();
|
||||
expect($stillMember->fresh()->account_id)->toBe($owner->account_id);
|
||||
});
|
||||
|
||||
test('forAccountMembers deletes every non-owner member of the account', function () {
|
||||
[
|
||||
'owner' => $owner,
|
||||
'member' => $member,
|
||||
] = strandedMemberOnSharedAccount();
|
||||
|
||||
SettleStrandedMember::forAccountMembers(
|
||||
$owner->account,
|
||||
exceptUserId: $owner->id,
|
||||
);
|
||||
|
||||
expect(User::find($member->id))->toBeNull();
|
||||
expect(User::find($owner->id))->not->toBeNull();
|
||||
});
|
||||
15
tests/Unit/Actions/User/StrandedSettlementTest.php
Normal file
15
tests/Unit/Actions/User/StrandedSettlementTest.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\User\StrandedSettlement;
|
||||
|
||||
test('merge combines media paths', function () {
|
||||
$merged = (new StrandedSettlement(
|
||||
mediaPaths: ['a.jpg'],
|
||||
))->merge(new StrandedSettlement(
|
||||
mediaPaths: ['b.jpg'],
|
||||
));
|
||||
|
||||
expect($merged->mediaPaths)->toBe(['a.jpg', 'b.jpg']);
|
||||
});
|
||||
33
tests/Unit/Actions/Workspace/PurgeWorkspaceTest.php
Normal file
33
tests/Unit/Actions/Workspace/PurgeWorkspaceTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Workspace\PurgeWorkspace;
|
||||
use App\Models\Media;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
test('purge workspace deletes the workspace and returns media paths', function () {
|
||||
Storage::fake();
|
||||
|
||||
$owner = User::factory()->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);
|
||||
});
|
||||
17
tests/Unit/Enums/Invite/ResultTest.php
Normal file
17
tests/Unit/Enums/Invite/ResultTest.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Invite\Result;
|
||||
|
||||
test('invite results map to the expected flash banner and style', function (Result $result, string $bannerKey, string $style) {
|
||||
expect($result->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'],
|
||||
]);
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
120
tests/Unit/Settings/DeleteAccountCopyTest.php
Normal file
120
tests/Unit/Settings/DeleteAccountCopyTest.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
|
||||
/**
|
||||
* Content markers for destructive copy. Key parity across locales is covered by
|
||||
* LocalizationParityTest (MorphMap-style). These markers catch stale translations
|
||||
* that still have the key but omit the invited-members / conditional-delete warning.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
$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<string, string>
|
||||
*/
|
||||
$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());
|
||||
});
|
||||
|
|
@ -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]);
|
||||
test('accountWorkspaces excludes memberships on other accounts', function () {
|
||||
$sharedOwner = User::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$personalAccountId = $user->account_id;
|
||||
|
||||
expect($member->belongsToWorkspace($workspace))->toBeTrue();
|
||||
$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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue