diff --git a/app/controllers/users_controller.ts b/app/controllers/users_controller.ts index a2585c8..bc43b3d 100644 --- a/app/controllers/users_controller.ts +++ b/app/controllers/users_controller.ts @@ -6,10 +6,14 @@ import { errorsReducer } from '#utils/index' import { UserService } from '#services/user_service' import { UserResponse } from '#interfaces/user' import { PageObject } from '@adonisjs/inertia/types' +import AuthService from '#services/auth_service' @inject() export default class UsersController { - constructor(private readonly service: UserService) {} + constructor( + private readonly authService: AuthService, + private readonly service: UserService + ) {} async index(ctx: HttpContext) { const searchTerm = ctx.request.qs().search || '' @@ -57,7 +61,14 @@ export default class UsersController { } } - async destroy() { - // TODO: Implement. + async destroy(ctx: HttpContext) { + const user = ctx.auth.user! + try { + await this.service.destroy(user) + await this.authService.destroy(ctx) + } catch (error) { + ctx.session.flash('errors', { message: 'Error deleting user.' }) + return ctx.response.redirect().back() + } } } diff --git a/app/services/user_service.ts b/app/services/user_service.ts index b30bbb8..52ffc77 100644 --- a/app/services/user_service.ts +++ b/app/services/user_service.ts @@ -60,6 +60,10 @@ export class UserService { await user.save() } + async destroy(user: User) { + await user.delete() + } + async storeAttachments(ctx: HttpContext) { const currentUserId = ctx.auth.user?.id! diff --git a/database/migrations/1732887344164_add_user_delete_cascade_posts_table.ts b/database/migrations/1732887344164_add_user_delete_cascade_posts_table.ts new file mode 100644 index 0000000..0e3ea28 --- /dev/null +++ b/database/migrations/1732887344164_add_user_delete_cascade_posts_table.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'posts' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.dropForeign('user_id'); + table.uuid('user_id').references('users.id').notNullable().onDelete('CASCADE').alter() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropForeign('user_id'); + table.uuid('user_id').references('users.id').notNullable().alter() + }) + } +} diff --git a/inertia/pages/users/settings.tsx b/inertia/pages/users/settings.tsx index d61136d..8be6055 100644 --- a/inertia/pages/users/settings.tsx +++ b/inertia/pages/users/settings.tsx @@ -4,6 +4,14 @@ import HeadOG from '@/components/generic/head_og' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use_toast' @@ -23,6 +31,7 @@ export default function UserSettings({ () => user.attachments.avatar?.link || undefined ) const [coverPreview, setCoverPreview] = useState(() => user.attachments.cover?.link || undefined) + const [deleteIntentModal, setDeleteIntentModal] = useState(false) if (!user) return <> @@ -30,7 +39,13 @@ export default function UserSettings({ const { toast } = useToast() - const { data, setData, patch, processing } = useForm<{ + const { + data, + setData, + patch, + delete: deleteReq, + processing, + } = useForm<{ name: string surname: string username: string @@ -51,7 +66,7 @@ export default function UserSettings({ function handleSubmit(e: React.FormEvent) { e.preventDefault() - patch(route('users.update', { params: { id: user?.id! } }).path, { + patch(route('users.update').path, { preserveState: true, preserveScroll: true, onSuccess: () => { @@ -86,9 +101,20 @@ export default function UserSettings({ fileReader.readAsDataURL(e.target.files[0]) } + function deleteAccount() { + deleteReq(route('users.destroy').path, { + preserveState: true, + preserveScroll: true, + data: undefined, + onSuccess: () => { + toast({ title: 'Account succesfully deleted.' }) + }, + }) + } + useEffect(() => { if (props?.errors && Object.entries(props.errors).length) { - toast({ title: 'Error updating profile details.' }) + toast({ title: props?.errors.message || 'Error updating profile details.' }) } }, [props?.errors]) @@ -99,7 +125,7 @@ export default function UserSettings({ description="Your profile settings on Social Adonis." url={route('settings.show').path} /> -
+
@@ -231,6 +257,45 @@ export default function UserSettings({ + + + + Danger zone + Permanently delete account. + + + + + + + + + Delete account + Confirm deletion + +

+ This is a non-reversible action. Everything related to your account, as well as + your connections and content will be lost forever - into the void. +

+
+ + +
+
+
+
+
) diff --git a/start/routes.ts b/start/routes.ts index b57aade..00b5c0f 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -71,8 +71,8 @@ router router .group(() => { router.get('/', [UsersController, 'index']).as('users.index') - router.patch(':id', [UsersController, 'update']).as('users.update') - router.delete(':id', [UsersController, 'destroy']).as('users.destroy') + router.patch('/', [UsersController, 'update']).as('users.update') + router.delete('/', [UsersController, 'destroy']).as('users.destroy') }) .prefix('users') diff --git a/tests/browser/pages/feed.spec.ts b/tests/browser/pages/feed.spec.ts index 8ba4667..fa12436 100644 --- a/tests/browser/pages/feed.spec.ts +++ b/tests/browser/pages/feed.spec.ts @@ -6,9 +6,11 @@ import { test } from '@japa/runner' test.group('Acessing feed', (group) => { group.each.setup(() => testUtils.db().truncate()) - test('Fails to access the feed without being authenticated', async ({ visit }) => { - const page = await visit(route('feed.show').path) + test('Successfully acesses the feed without being authenticated', async ({ visit }) => { + const url = route('feed.show').path + const page = await visit(url) await page.assertTextContains('body', 'Sign in') + await page.assertPath(url) }) test('Successfully acesses the feed while authenticated', async ({ visit, browserContext }) => { diff --git a/tests/browser/pages/user_feed.spec.ts b/tests/browser/pages/user_feed.spec.ts index 9828eb7..518770e 100644 --- a/tests/browser/pages/user_feed.spec.ts +++ b/tests/browser/pages/user_feed.spec.ts @@ -6,16 +6,18 @@ import { test } from '@japa/runner' test.group('Acessing user profile feed', (group) => { group.each.setup(() => testUtils.db().truncate()) - test('Fails to access the user profile feed without being authenticated', async ({ visit }) => { + test('Successfully acesses the user profile feed without being authenticated', async ({ + visit, + }) => { const user = await UserFactory.create() - const page = await visit( - route('users.show', { - params: { - id: user.id, - }, - }).path - ) + const url = route('users.show', { + params: { + id: user.id, + }, + }).path + const page = await visit(url) await page.assertTextContains('body', 'Sign in') + await page.assertPath(url) }) test('Successfully acesses own user profile feed while authenticated', async ({ diff --git a/tests/browser/user/settings.spec.ts b/tests/browser/user/settings.spec.ts index 294dd09..a74667e 100644 --- a/tests/browser/user/settings.spec.ts +++ b/tests/browser/user/settings.spec.ts @@ -16,6 +16,11 @@ test.group('User settings', (group) => { user = await UserFactory.create() }) + test('Fails to access the users settings without being authenticated', async ({ visit }) => { + const page = await visit(route('settings.show').path) + await page.assertTextContains('body', 'Sign in') + }) + test('Sucessfully updates profile', async ({ visit, browserContext, assert }) => { const authUser = user! await browserContext.loginAs(authUser) @@ -79,4 +84,14 @@ test.group('User settings', (group) => { surname: 'The surname field format is invalid', }) }) + + test('Successfully deletes profile', async ({ visit, browserContext }) => { + const authUser = user! + await browserContext.loginAs(authUser) + const page = await visit(url) + await page.locator('button.trigger-delete-account').click() + await page.locator('button.delete-account').click() + await page.waitForURL('/') + await page.assertTextContains('body', 'Sign in') + }) })