feat: improvements on deleting account.
This commit is contained in:
parent
b03ff582c0
commit
f0ffe83f83
12 changed files with 313 additions and 236 deletions
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue