feat: improvements on deleting account.

This commit is contained in:
Paulo Castellano 2026-01-21 19:22:45 -03:00
parent b03ff582c0
commit f0ffe83f83
12 changed files with 313 additions and 236 deletions

View file

@ -2,7 +2,6 @@
namespace App\Concerns;
use App\Models\Language;
use App\Models\User;
use Illuminate\Validation\Rule;
@ -18,20 +17,9 @@ protected function profileRules(string|int|null $userId = null): array
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
'language_id' => $this->languageRules(),
];
}
/**
* Get the validation rules used to validate user language.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function languageRules(): array
{
return ['required', Rule::exists(Language::class, 'id')];
}
/**
* Get the validation rules used to validate user names.
*

View file

@ -27,6 +27,9 @@ public function update(PasswordUpdateRequest $request): RedirectResponse
'password' => $request->password,
]);
session()->flash('flash.banner', 'Password updated successfully!');
session()->flash('flash.bannerStyle', 'success');
return back();
}
}

View file

@ -2,14 +2,15 @@
namespace App\Http\Controllers\Settings;
use App\Enums\UserWorkspace\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use App\Models\Language;
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;
@ -23,7 +24,6 @@ public function edit(Request $request): Response
return Inertia::render('settings/Profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
'languages' => Language::all(),
]);
}
@ -40,9 +40,31 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
$request->user()->save();
session()->flash('flash.banner', 'Profile updated successfully!');
session()->flash('flash.bannerStyle', 'success');
return to_route('profile.edit');
}
/**
* Update the user's language.
*/
public function updateLanguage(Request $request): RedirectResponse
{
$request->validate([
'language_id' => ['required', 'exists:languages,id'],
]);
$request->user()->update([
'language_id' => $request->language_id,
]);
session()->flash('flash.banner', 'Language updated successfully!');
session()->flash('flash.bannerStyle', 'success');
return back();
}
/**
* Delete the user's profile.
*/
@ -50,6 +72,45 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
{
$user = $request->user();
DB::transaction(function () use ($user) {
// Cancel active Stripe subscription and delete subscription records
if ($user->subscribed('default')) {
$user->subscription('default')->cancelNow();
}
$user->subscriptions()->delete();
// Clear current workspace reference
$user->update(['current_workspace_id' => null]);
// Delete all workspaces owned by the user
$ownedWorkspaces = $user->workspaces()->wherePivot('role', Role::Owner->value)->get();
foreach ($ownedWorkspaces as $workspace) {
// Update members who have this as current_workspace_id
foreach ($workspace->members as $member) {
if ($member->id !== $user->id && $member->current_workspace_id === $workspace->id) {
// Find another workspace for this member
$otherWorkspace = $member->workspaces()
->where('workspaces.id', '!=', $workspace->id)
->first();
$member->update(['current_workspace_id' => $otherWorkspace?->id]);
}
}
$workspace->posts()->delete();
$workspace->socialAccounts()->delete();
$workspace->hashtags()->delete();
$workspace->labels()->delete();
$workspace->invites()->delete();
$workspace->members()->detach();
$workspace->delete();
}
// Remove user from workspaces where they are a member
$user->workspaces()->detach();
});
Auth::logout();
$user->delete();

View file

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Models\Language;
use Illuminate\Http\Request;
use Inertia\Middleware;
@ -62,6 +63,7 @@ public function share(Request $request): array
'env' => config('app.env'),
'locale' => app()->getLocale(),
'selfHosted' => config('trypost.self_hosted'),
'languages' => Language::all(),
];
}
}

View file

@ -12,7 +12,7 @@ class LanguageSeeder extends Seeder
*/
public function run(): void
{
Language::create(['name' => 'English', 'code' => 'en-US']);
Language::create(['name' => 'Português', 'code' => 'pt-BR']);
Language::create(['name' => 'English (US)', 'code' => 'en-US']);
Language::create(['name' => 'Português (BR)', 'code' => 'pt-BR']);
}
}

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { IconBrandDiscord, IconCalendar, IconCheck, IconSelector, IconFileText, IconHash, IconLogout, IconPlus, IconSettings, IconAffiliate, IconPencil, IconFileCheck, IconTag, IconUser, IconClock, IconMessageCircle, IconBell, IconBook, IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons-vue';
import { IconBrandDiscord, IconCalendar, IconCheck, IconSelector, IconFileText, IconHash, IconLogout, IconPlus, IconSettings, IconAffiliate, IconPencil, IconFileCheck, IconTag, IconUser, IconClock, IconMessageCircle, IconBell, IconBook, IconSun, IconMoon, IconDeviceDesktop, IconLanguage } from '@tabler/icons-vue';
import { Button } from '@/components/ui/button';
import { create as createPost } from '@/actions/App/Http/Controllers/PostController';
import { updateLanguage } from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@ -54,10 +55,18 @@ interface Workspace {
};
}
interface Language {
id: string;
name: string;
code: string;
}
const page = usePage();
const auth = computed(() => page.props.auth);
const currentWorkspace = computed<Workspace | null>(() => page.props.currentWorkspace as Workspace | null);
const workspaces = computed<Workspace[]>(() => page.props.workspaces as Workspace[]);
const languages = computed<Language[]>(() => page.props.languages as Language[]);
const currentLanguage = computed(() => languages.value.find(l => l.id === auth.value.user.language_id));
const { getInitials } = useInitials();
const { appearance, updateAppearance } = useAppearance();
@ -158,6 +167,12 @@ function switchWorkspace(workspace: Workspace) {
});
}
function switchLanguage(languageId: string) {
router.patch(updateLanguage.url(), { language_id: languageId }, {
preserveScroll: true,
});
}
function createWorkspace() {
router.visit(createWorkspaceRoute.url());
}
@ -211,7 +226,7 @@ function handleLogout() {
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ auth.user.name }}</span>
<span class="truncate text-xs text-muted-foreground">{{ auth.user.email
}}</span>
}}</span>
</div>
</div>
</DropdownMenuLabel>
@ -277,6 +292,27 @@ function handleLogout() {
<DropdownMenuSeparator />
<!-- Language -->
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<IconLanguage class="mr-2 size-4" />
<span>Language: {{ currentLanguage?.name || 'Select' }}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup :model-value="currentLanguage?.id"
@update:model-value="switchLanguage">
<DropdownMenuRadioItem v-for="language in languages" :key="language.id"
:value="language.id">
<span>{{ language.name }}</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<!-- User Actions -->
<DropdownMenuGroup>
<DropdownMenuItem as-child>

View file

@ -9,9 +9,7 @@ import {
} from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
const show = ref(false);
const animate = ref(false);
const style = computed(() => usePage().props.flash?.bannerStyle || 'success');
const message = computed(() => usePage().props.flash?.banner || '');
@ -22,23 +20,13 @@ watch(
(newMessage) => {
if (newMessage) {
show.value = true;
// Reseta a animação
animate.value = false;
// Inicia a animação logo após mostrar
setTimeout(() => {
animate.value = true;
}, 10);
// Limpa timeout anterior se existir
if (timeoutId) {
clearTimeout(timeoutId);
}
// Define novo timeout
timeoutId = setTimeout(() => {
show.value = false;
animate.value = false;
timeoutId = null;
}, 3000);
}
@ -50,78 +38,40 @@ watch(
<template>
<div
v-if="show && message"
class="pointer-events-none fixed inset-0 z-50 flex px-4 py-6 sm:items-start sm:p-6"
class="pointer-events-none fixed inset-0 z-50 flex items-end justify-end p-6"
>
<div
class="absolute right-5 bottom-5 flex w-full flex-col items-end space-y-4"
class="pointer-events-auto w-full max-w-sm rounded-lg border bg-background p-4 shadow-lg"
>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="ring-opacity-5 pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-lg bg-zinc-800 shadow-2xl ring-1 ring-transparent dark:ring-zinc-700"
<div class="flex items-start gap-3">
<IconCircleCheck
v-if="style === 'success'"
class="size-5 shrink-0 text-green-500"
/>
<IconCircleX
v-if="style === 'danger'"
class="size-5 shrink-0 text-destructive"
/>
<IconInfoCircle
v-if="style === 'info'"
class="size-5 shrink-0 text-blue-500"
/>
<IconAlertTriangle
v-if="style === 'warning'"
class="size-5 shrink-0 text-yellow-500"
/>
<p class="flex-1 text-sm font-medium text-foreground">
{{ message }}
</p>
<button
type="button"
@click="show = false"
class="shrink-0 rounded-md text-muted-foreground hover:text-foreground focus:outline-none"
>
<!-- Barra de progresso animada -->
<div
class="absolute top-0 left-0 h-full transition-all duration-[3000ms] ease-linear"
:class="{
'w-0': !animate,
'w-full': animate,
'bg-green-500/10': style === 'success',
'bg-red-500/10': style === 'danger',
'bg-blue-500/10': style === 'info',
'bg-yellow-500/10': style === 'warning',
}"
/>
<div class="relative p-4">
<div class="flex items-start">
<div class="mt-0.5 flex-shrink-0">
<IconCircleCheck
v-if="style == 'success'"
class="h-6 w-6 stroke-2 text-green-400"
aria-hidden="true"
/>
<IconCircleX
v-if="style == 'danger'"
class="h-6 w-6 stroke-2 text-red-400"
aria-hidden="true"
/>
<IconInfoCircle
v-if="style == 'info'"
class="h-6 w-6 stroke-2 text-blue-400"
aria-hidden="true"
/>
<IconAlertTriangle
v-if="style == 'warning'"
class="h-6 w-6 stroke-2 text-yellow-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-white">
{{ message }}
</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<button
type="button"
@click="show = false"
class="inline-flex rounded-md bg-transparent text-zinc-400 hover:text-zinc-500 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
>
<span class="sr-only">Close</span>
<IconX class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</transition>
<span class="sr-only">Close</span>
<IconX class="size-4" />
</button>
</div>
</div>
</div>
</template>

View file

@ -3,6 +3,7 @@ import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import Toast from '@/components/Toast.vue';
import type { BreadcrumbItemType } from '@/types';
interface Props {
@ -22,4 +23,5 @@ withDefaults(defineProps<Props>(), {
<slot />
</AppContent>
</AppShell>
<Toast />
</template>

View file

@ -45,7 +45,7 @@ const breadcrumbItems: BreadcrumbItem[] = [
'current_password',
]"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="current_password">Current password</Label>
@ -53,7 +53,6 @@ const breadcrumbItems: BreadcrumbItem[] = [
id="current_password"
name="current_password"
type="password"
class="mt-1 block w-full"
autocomplete="current-password"
placeholder="Current password"
/>
@ -66,7 +65,6 @@ const breadcrumbItems: BreadcrumbItem[] = [
id="password"
name="password"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="New password"
/>
@ -74,41 +72,23 @@ const breadcrumbItems: BreadcrumbItem[] = [
</div>
<div class="grid gap-2">
<Label for="password_confirmation"
>Confirm password</Label
>
<Label for="password_confirmation">Confirm password</Label>
<Input
id="password_confirmation"
name="password_confirmation"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-password-button"
>Save password</Button
>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
<Button
:disabled="processing"
data-test="update-password-button"
>
Save password
</Button>
</Form>
</div>
</SettingsLayout>

View file

@ -1,12 +1,10 @@
<script setup lang="ts">
import { Form, Head, Link, usePage } from '@inertiajs/vue3';
import { ref } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import LanguageCombobox from '@/components/LanguageCombobox.vue';
import PhotoUpload from '@/components/PhotoUpload.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -17,25 +15,16 @@ import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
import { type BreadcrumbItem } from '@/types';
interface Language {
id: string;
name: string;
code: string;
}
interface Props {
mustVerifyEmail: boolean;
status?: string;
languages: Language[];
}
const props = defineProps<Props>();
defineProps<Props>();
const page = usePage();
const user = page.props.auth.user;
const languageId = ref(user.language_id);
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Profile settings',
@ -51,114 +40,91 @@ const breadcrumbItems: BreadcrumbItem[] = [
<h1 class="sr-only">Profile Settings</h1>
<SettingsLayout>
<div class="flex flex-col space-y-6">
<HeadingSmall
title="Profile information"
description="Update your name and email address"
/>
<div class="grid gap-2">
<Label>Avatar</Label>
<PhotoUpload
:model-id="user.id"
model-type="App\Models\User"
:photo="user.avatar"
collection="avatar"
:reload-only="['auth']"
rounded="full"
<div class="space-y-10">
<div class="space-y-6">
<HeadingSmall
title="Profile information"
description="Update your name and email address"
/>
</div>
<Form
v-bind="ProfileController.update.form()"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
class="mt-1 block w-full"
name="name"
:default-value="user.name"
required
autocomplete="name"
placeholder="Full name"
/>
<InputError class="mt-2" :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
class="mt-1 block w-full"
name="email"
:default-value="user.email"
required
autocomplete="username"
placeholder="Email address"
<Label>Avatar</Label>
<PhotoUpload
:model-id="user.id"
model-type="App\Models\User"
:photo="user.avatar"
collection="avatar"
:reload-only="['auth']"
rounded="full"
/>
<InputError class="mt-2" :message="errors.email" />
</div>
<div class="grid gap-2">
<Label>Language</Label>
<input type="hidden" name="language_id" :value="languageId" />
<LanguageCombobox
v-model="languageId"
:languages="props.languages"
/>
<InputError class="mt-2" :message="errors.language_id" />
</div>
<div v-if="mustVerifyEmail && !user.email_verified_at">
<p class="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.
<Link
:href="send()"
as="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to resend the verification email.
</Link>
</p>
<div
v-if="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email
address.
<Form
v-bind="ProfileController.update.form()"
class="space-y-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
name="name"
:default-value="user.name"
required
autocomplete="name"
placeholder="Full name"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
:default-value="user.email"
required
autocomplete="username"
placeholder="Email address"
/>
<InputError :message="errors.email" />
</div>
<div v-if="mustVerifyEmail && !user.email_verified_at">
<p class="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.
<Link
:href="send()"
as="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to resend the verification email.
</Link>
</p>
<div
v-if="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email
address.
</div>
</div>
</div>
<div class="flex items-center gap-4">
<Button
:disabled="processing"
data-test="update-profile-button"
>Save</Button
>
Save
</Button>
</Form>
</div>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</Form>
<hr class="border-border" />
<DeleteUser />
</div>
<DeleteUser />
</SettingsLayout>
</AppLayout>
</template>

View file

@ -3,11 +3,11 @@
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::middleware(['auth'])->group(function () {
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::patch('settings/language', [ProfileController::class, 'updateLanguage'])->name('profile.language');
});
Route::middleware(['auth', 'verified'])->group(function () {

View file

@ -1,5 +1,6 @@
<?php
use App\Models\Language;
use App\Models\User;
test('profile page is displayed', function () {
@ -20,7 +21,6 @@
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
'language_id' => $user->language_id,
]);
$response
@ -42,7 +42,6 @@
->patch(route('profile.update'), [
'name' => 'Test User',
'email' => $user->email,
'language_id' => $user->language_id,
]);
$response
@ -52,6 +51,36 @@
expect($user->refresh()->email_verified_at)->not->toBeNull();
});
test('user can update their language', function () {
$user = User::factory()->create();
$newLanguage = Language::factory()->create();
$response = $this
->actingAs($user)
->from(route('posts.index'))
->patch(route('profile.language'), [
'language_id' => $newLanguage->id,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('posts.index'));
expect($user->refresh()->language_id)->toBe($newLanguage->id);
});
test('user cannot update language with invalid language id', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('profile.language'), [
'language_id' => '00000000-0000-0000-0000-000000000000',
]);
$response->assertSessionHasErrors('language_id');
});
test('user can delete their account', function () {
$user = User::factory()->create();
@ -84,4 +113,64 @@
->assertRedirect(route('profile.edit'));
expect($user->fresh())->not->toBeNull();
});
});
test('deleting account updates members current_workspace_id when their workspace is deleted', function () {
$owner = User::factory()->create();
$member = User::factory()->create();
// Create a workspace owned by the owner
$workspace = \App\Models\Workspace::factory()->create(['user_id' => $owner->id]);
$owner->workspaces()->attach($workspace->id, ['role' => \App\Enums\UserWorkspace\Role::Owner->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' => \App\Enums\UserWorkspace\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('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 = \App\Models\Workspace::factory()->create(['user_id' => $owner->id]);
$owner->workspaces()->attach($workspaceToDelete->id, ['role' => \App\Enums\UserWorkspace\Role::Owner->value]);
$owner->update(['current_workspace_id' => $workspaceToDelete->id]);
// Create another workspace owned by a different user
$otherWorkspace = \App\Models\Workspace::factory()->create(['user_id' => $otherOwner->id]);
$otherOwner->workspaces()->attach($otherWorkspace->id, ['role' => \App\Enums\UserWorkspace\Role::Owner->value]);
// Add member to both workspaces
$member->workspaces()->attach($workspaceToDelete->id, ['role' => \App\Enums\UserWorkspace\Role::Member->value]);
$member->workspaces()->attach($otherWorkspace->id, ['role' => \App\Enums\UserWorkspace\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('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);
});