Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/Http/Controllers/AccountDeletionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;

class AccountDeletionController extends Controller
{
/**
* Show the signed account deletion confirmation page.
*/
public function showConfirm(Request $request, User $user)
{
$hasValidSignature = $request->hasValidSignature();
$hasValidHash = ! empty($user->email) && hash_equals(sha1($user->email), (string) $request->query('hash'));

if (! $hasValidSignature || ! $hasValidHash) {
return Inertia::render('Account/ConfirmDeletion', [
'isValid' => false,
'user' => null,
'confirmUrl' => '',
]);
}

return Inertia::render('Account/ConfirmDeletion', [
'isValid' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'confirmUrl' => $request->fullUrl(),
]);
}

/**
* Process the permanent deletion requested via signed URL.
*/
public function confirmDelete(Request $request, User $user)
{
if (! hash_equals(sha1($user->email), (string) $request->query('hash'))) {
abort(403, 'Invalid deletion token.');
}

// If currently authenticated as this user, log them out and invalidate session
if (Auth::id() === $user->id) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}

$user->delete();

return redirect('/')
->with('success', 'আপনার অ্যাকাউন্টটি সফলভাবে মুছে ফেলা হয়েছে।');
}
}
25 changes: 25 additions & 0 deletions app/Http/Controllers/Admin/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
use App\Http\Requests\User\StoreUserRequest;
use App\Http\Requests\User\UpdateUserRequest;
use App\Mail\AccountDeletedMail;
use App\Mail\AccountDeletionLinkMail;
use App\Models\User;
use App\Notifications\WelcomeNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Inertia\Inertia;
use Spatie\Permission\Models\Permission;

Expand Down Expand Up @@ -170,6 +172,29 @@ public function destroy(User $user)
->with('success', 'User deleted successfully.');
}

/**
* Send a temporary signed account deletion link to the user.
*/
public function sendDeleteLink(User $user)
{
if (empty($user->email)) {
return back()->with('error', 'This user does not have an email address.');
}

$signedUrl = URL::temporarySignedRoute(
'account.delete.verify',
now()->addDays(7),
[
'user' => $user->id,
'hash' => sha1($user->email),
]
);

Mail::to($user->email)->queue(new AccountDeletionLinkMail($user, $signedUrl));

return back()->with('success', "Deletion link sent successfully to {$user->email}.");
}

/**
* Log in as the specified user.
*/
Expand Down
49 changes: 49 additions & 0 deletions app/Mail/AccountDeletionLinkMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\Mail;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class AccountDeletionLinkMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;

public function __construct(
public User $user,
public string $signedUrl,
) {}

public function envelope(): Envelope
{
return new Envelope(
subject: 'আপনার অ্যাকাউন্ট মুছে ফেলার কনফার্মেশন লিংক',
);
}

public function content(): Content
{
$name = $this->user->name ? "Hello {$this->user->name}," : 'প্রিয় শিক্ষার্থী,';

return new Content(
view: 'emails.default',
with: [
'subject' => 'আপনার অ্যাকাউন্ট মুছে ফেলার কনফার্মেশন লিংক',
'greeting' => $name,
'lines' => [
'আমরা আপনার অ্যাকাউন্ট মুছে ফেলার অনুরোধটি পেয়েছি।',
'আপনি যদি আপনার HSCStack অ্যাকাউন্টটি ডিলিট করতে চান, তাহলে নিচের বাটনে ক্লিক করে কনফার্মেশন সম্পন্ন করুন।',
'<strong>সতর্কতা:</strong> এই লিংকের মেয়াদ আগামী <strong>৭ দিন</strong> পর্যন্ত কার্যকর থাকবে। অ্যাকাউন্ট মুছে ফেলার পর আপনার সব ডাটা স্থায়ীভাবে ডিলিট হয়ে যাবে এবং তা আর পুনরুদ্ধার করা সম্ভব হবে না।',
'আপনি যদি অ্যাকাউন্ট ডিলিট করতে না চান, তবে এই ইমেইলটি উপেক্ষা করুন। আপনার অ্যাকাউন্ট সুরক্ষিত থাকবে।',
],
'actionText' => 'অ্যাকাউন্ট ডিলিট করুন (Confirm Deletion)',
'actionUrl' => $this->signedUrl,
],
);
}
}
153 changes: 149 additions & 4 deletions resources/js/components/admin/UserRow.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { LogIn, Pencil, Trash2, Ban } from 'lucide-vue-next';
import { LogIn, Pencil, Trash2, Ban, Mail } from 'lucide-vue-next';
import { ref } from 'vue';
import BaseModal from '@/components/BaseModal.vue';
import ChatBanModal from '@/components/ChatBanModal.vue';
import StatusBadge from '@/components/StatusBadge.vue';
import VerifiedBadge from '@/components/VerifiedBadge.vue';
Expand All @@ -16,6 +17,8 @@ defineProps({
const page = usePage();
const userId = page.props.auth.user.id;
const isBanModalOpen = ref(false);
const isDeleteModalOpen = ref(false);
const isSendingDeleteLink = ref(false);

const isBanned = (user: any) => {
const banUntil = user?.banned_until;
Expand All @@ -41,11 +44,42 @@ const loginAsUser = (targetUser: any) => {
}
};

const deleteUser = (id: number) => {
if (confirm('Are you sure you want to delete this user?')) {
const openDeleteModal = () => {
isDeleteModalOpen.value = true;
};

const handleHardDelete = (id: number) => {
if (
confirm(
'Are you absolutely sure you want to permanently delete this user now? This action cannot be undone.',
)
) {
isDeleteModalOpen.value = false;
router.delete(`/admin/users/${id}`);
}
};

const handleSendDeleteLink = (id: number) => {
if (
!confirm(
'Are you sure you want to send a signed account deletion link to this user? They will receive an email to confirm deletion.',
)
) {
return;
}

isSendingDeleteLink.value = true;
router.post(
`/admin/users/${id}/send-delete-link`,
{},
{
onFinish: () => {
isSendingDeleteLink.value = false;
isDeleteModalOpen.value = false;
},
},
);
};
</script>

<template>
Expand Down Expand Up @@ -178,7 +212,7 @@ const deleteUser = (id: number) => {

<button
v-if="user.id !== userId && can('delete users')"
@click="deleteUser(user.id)"
@click="openDeleteModal"
type="button"
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600 dark:text-gray-500 dark:hover:bg-rose-500/10 dark:hover:text-rose-400"
title="Delete user"
Expand All @@ -193,5 +227,116 @@ const deleteUser = (id: number) => {
:user="user"
@close="isBanModalOpen = false"
/>

<!-- Account Deletion Action Modal -->
<BaseModal
:is-open="isDeleteModalOpen"
title="Delete User Account"
description="Choose how you want to handle the deletion of this account."
maxWidth="md"
@close="isDeleteModalOpen = false"
>
<div class="space-y-4 p-4 sm:p-6">
<div
class="rounded-xl border border-slate-100 bg-slate-50 p-3.5 dark:border-gray-800 dark:bg-gray-800/50"
>
<p
class="text-xs font-medium tracking-wider text-slate-500 uppercase dark:text-gray-400"
>
Target User
</p>
<p
class="text-sm font-semibold text-slate-900 dark:text-white"
>
{{ user.name }}
</p>
<p
v-if="user.email"
class="font-mono text-xs text-slate-600 dark:text-gray-400"
>
{{ user.email }}
</p>
</div>

<div class="space-y-3">
<!-- Option 1: Send Deletion Link -->
<button
type="button"
:disabled="!user.email || isSendingDeleteLink"
@click="handleSendDeleteLink(user.id)"
class="group flex w-full items-start gap-3 rounded-xl border border-indigo-100 bg-indigo-50/50 p-3.5 text-left transition hover:border-indigo-300 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-indigo-950/50 dark:bg-indigo-950/20 dark:hover:border-indigo-800"
>
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-100 text-indigo-600 group-hover:bg-indigo-200 dark:bg-indigo-900/40 dark:text-indigo-400"
>
<Mail class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between">
<span
class="text-sm font-bold text-indigo-950 dark:text-indigo-200"
>Send Deletion Link to User</span
>
<span
class="rounded-full bg-indigo-100 px-2 py-0.5 text-[10px] font-semibold text-indigo-700 uppercase dark:bg-indigo-900/60 dark:text-indigo-300"
>Recommended</span
>
</div>
<p
class="mt-0.5 text-xs leading-relaxed text-slate-600 dark:text-gray-400"
>
Sends a secure, 7-day signed authorization link
to their email address. The user confirms and
deletes the account themselves.
</p>
<p
v-if="!user.email"
class="mt-1 text-[11px] font-medium text-amber-600 dark:text-amber-400"
>
User has no email address configured.
</p>
</div>
</button>

<!-- Option 2: Delete Instantly (Hard Delete) -->
<button
type="button"
@click="handleHardDelete(user.id)"
class="group flex w-full items-start gap-3 rounded-xl border border-rose-100 bg-rose-50/50 p-3.5 text-left transition hover:border-rose-300 hover:bg-rose-50 dark:border-rose-950/50 dark:bg-rose-950/20 dark:hover:border-rose-800"
>
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-rose-100 text-rose-600 group-hover:bg-rose-200 dark:bg-rose-900/40 dark:text-rose-400"
>
<Trash2 class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<span
class="text-sm font-bold text-rose-950 dark:text-rose-200"
>Delete Instantly (Hard Delete)</span
>
<p
class="mt-0.5 text-xs leading-relaxed text-slate-600 dark:text-gray-400"
>
Permanently wipe the account and all associated
data immediately. Sends a farewell notification
email if email is present.
</p>
</div>
</button>
</div>
</div>

<template #footer>
<div class="flex justify-end">
<button
type="button"
@click="isDeleteModalOpen = false"
class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-xs font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
</template>
</BaseModal>
</div>
</template>
Loading
Loading