Skip to content

Commit 7196ad3

Browse files
committed
Add account deletion feature
1 parent 89c3a0f commit 7196ad3

11 files changed

Lines changed: 285 additions & 2 deletions

File tree

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,15 @@
8080
"preset": "ts-jest",
8181
"testEnvironment": "node",
8282
"transform": {
83-
"node_modules/variables/.+\\.(j|t)sx?$": "ts-jest"
83+
"node_modules/variables/.+\\.(j|t)sx?$": ["ts-jest", {}],
84+
"^.+\\.ts$": ["ts-jest", { "tsconfig": { "module": "commonjs", "preserveValueImports": false } }]
8485
},
8586
"transformIgnorePatterns": [
8687
"node_modules/(?!variables/.*)"
87-
]
88+
],
89+
"moduleNameMapper": {
90+
"^\\$lib/(.*)$": "<rootDir>/src/lib/$1",
91+
"^\\$env/static/public$": "<rootDir>/src/__mocks__/env-public.ts"
92+
}
8893
}
8994
}

src/__mocks__/env-public.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const PUBLIC_ADMIN_USER_GITHUB_ID = '999999';
2+
export const PUBLIC_MAINTENANCE_ENABLED = 'false';
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<script lang="ts">
2+
export let showModal: boolean;
3+
export let username: string;
4+
export let onClose: () => void;
5+
export let onConfirm: () => Promise<void>;
6+
7+
let inputValue = '';
8+
let loading = false;
9+
10+
$: canConfirm = inputValue === username && !loading;
11+
$: confirmButtonClass = canConfirm
12+
? 'border-red-500/60 hover:border-red-400 text-red-400'
13+
: 'border-base-700 text-base-600 cursor-not-allowed';
14+
15+
async function handleConfirm() {
16+
loading = true;
17+
try {
18+
await onConfirm();
19+
} finally {
20+
loading = false;
21+
}
22+
}
23+
24+
function handleClose() {
25+
inputValue = '';
26+
onClose();
27+
}
28+
</script>
29+
30+
{#if showModal}
31+
<div
32+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
33+
on:click|self={handleClose}
34+
on:keydown={(e) => e.key === 'Escape' && handleClose()}
35+
role="dialog"
36+
aria-modal="true"
37+
aria-labelledby="delete-modal-title"
38+
tabindex="-1"
39+
>
40+
<div class="bg-base-900 border border-base-700 rounded-xl p-6 w-full max-w-md flex flex-col gap-4 mx-4">
41+
<h2 id="delete-modal-title" class="text-xl font-semibold">Delete your account</h2>
42+
<p class="text-base-400 text-sm">
43+
This action is <span class="text-white font-semibold">permanent and irreversible</span>.
44+
Your profile, configurations, and all associated data will be deleted.
45+
</p>
46+
<div class="flex flex-col gap-2">
47+
<label for="confirm-username" class="text-sm text-base-400">
48+
Type <span class="font-mono text-white">{username}</span> to confirm
49+
</label>
50+
<input
51+
id="confirm-username"
52+
type="text"
53+
bind:value={inputValue}
54+
placeholder={username}
55+
class="bg-black/30 border border-base-700 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:border-base-400 w-full"
56+
/>
57+
</div>
58+
<div class="flex gap-2 justify-end">
59+
<button
60+
class="px-4 py-2 rounded-lg border border-base-700 hover:border-base-400 text-sm transition-all"
61+
on:click={handleClose}
62+
disabled={loading}
63+
>
64+
Cancel
65+
</button>
66+
<button
67+
class="px-4 py-2 rounded-lg border text-sm transition-all {confirmButtonClass}"
68+
on:click={handleConfirm}
69+
disabled={!canConfirm}
70+
>
71+
{loading ? 'Deleting…' : 'Delete account'}
72+
</button>
73+
</div>
74+
</div>
75+
</div>
76+
{/if}

src/lib/server/prisma/users/service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ export async function getUserByUsername(username: string): Promise<User> {
5050
});
5151
return user;
5252
}
53+
54+
export async function deleteUser(userId: number): Promise<void> {
55+
await prismaClient.user.delete({ where: { id: userId } });
56+
}

src/lib/trpc/deleteAccount.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
jest.mock('$lib/server/prisma/users/service', () => ({ deleteUser: jest.fn() }));
2+
jest.mock('$lib/server/auth/services', () => ({ logout: jest.fn() }));
3+
jest.mock('$lib/utils', () => ({ isAdmin: jest.fn(() => false) }));
4+
5+
import { t } from './t';
6+
import { deleteAccount } from './procedures/deleteAccount';
7+
import { deleteUser } from '$lib/server/prisma/users/service';
8+
import { logout } from '$lib/server/auth/services';
9+
import { isAdmin } from '$lib/utils';
10+
import { makeCaller, mockUser, mockCookies } from './test-utils';
11+
12+
const mockDeleteUser = jest.mocked(deleteUser);
13+
const mockLogout = jest.mocked(logout);
14+
const mockIsAdmin = jest.mocked(isAdmin);
15+
16+
const router = t.router({ deleteAccount });
17+
const createCaller = t.createCallerFactory(router);
18+
19+
beforeEach(() => jest.clearAllMocks());
20+
21+
describe('deleteAccount', () => {
22+
it('deletes the authenticated user and clears the session', async () => {
23+
await makeCaller(createCaller, mockUser).deleteAccount();
24+
25+
expect(mockDeleteUser).toHaveBeenCalledTimes(1);
26+
expect(mockDeleteUser).toHaveBeenCalledWith(mockUser.id);
27+
expect(mockLogout).toHaveBeenCalledWith(mockCookies);
28+
});
29+
30+
it('can only delete the authenticated user — no input means no other target is possible', async () => {
31+
const anotherUser = { ...mockUser, id: 999, username: 'anotheruser' };
32+
33+
await makeCaller(createCaller, anotherUser).deleteAccount();
34+
35+
expect(mockDeleteUser).toHaveBeenCalledWith(anotherUser.id);
36+
expect(mockDeleteUser).not.toHaveBeenCalledWith(mockUser.id);
37+
expect(mockLogout).toHaveBeenCalledWith(mockCookies);
38+
});
39+
40+
it('throws UNAUTHORIZED and skips deletion when not authenticated', async () => {
41+
await expect(makeCaller(createCaller, null).deleteAccount()).rejects.toMatchObject({
42+
code: 'UNAUTHORIZED',
43+
});
44+
45+
expect(mockDeleteUser).not.toHaveBeenCalled();
46+
expect(mockLogout).not.toHaveBeenCalled();
47+
});
48+
49+
it('throws FORBIDDEN and skips deletion for admin accounts', async () => {
50+
mockIsAdmin.mockReturnValue(true);
51+
52+
await expect(makeCaller(createCaller, mockUser).deleteAccount()).rejects.toMatchObject({
53+
code: 'FORBIDDEN',
54+
});
55+
56+
expect(mockDeleteUser).not.toHaveBeenCalled();
57+
expect(mockLogout).not.toHaveBeenCalled();
58+
});
59+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { TRPCError } from '@trpc/server';
2+
import { logout } from '$lib/server/auth/services';
3+
import { deleteUser } from '$lib/server/prisma/users/service';
4+
import { isAdmin } from '$lib/utils';
5+
import * as middlewares from '../middlewares/auth';
6+
import { t } from '../t';
7+
8+
export const deleteAccount = t.procedure
9+
.use(middlewares.isAuthenticated)
10+
.mutation(async ({ ctx }) => {
11+
const user = ctx.getAuthenticatedUser();
12+
if (isAdmin(user)) {
13+
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin account cannot be deleted' });
14+
}
15+
await deleteUser(user.id);
16+
logout(ctx.event.cookies);
17+
});

src/lib/trpc/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as middlewares from './middlewares/auth';
2+
import { deleteAccount } from './procedures/deleteAccount';
23
import { t } from './t';
34
import { z } from 'zod';
45
import {
@@ -596,6 +597,7 @@ export const router = t.router({
596597
});
597598
}),
598599

600+
deleteAccount,
599601
getDotfyleStatisitics: t.procedure.query(async () => {
600602
const installsP = prismaClient.neovimConfigPlugins.count();
601603
const usersP = prismaClient.user.count();

src/lib/trpc/test-utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { TRPCError } from '@trpc/server';
2+
import type { Context } from './context';
3+
4+
type User = Context['user'];
5+
6+
export const mockCookies = {};
7+
8+
export const mockUser = {
9+
id: 1,
10+
githubId: 12345,
11+
username: 'testuser',
12+
avatarUrl: 'https://example.com/avatar.png',
13+
createdAt: new Date(),
14+
};
15+
16+
export function makeContext(user: User): Context {
17+
return {
18+
user,
19+
event: { cookies: mockCookies } as Context['event'],
20+
getAuthenticatedUser() {
21+
if (!user) throw new TRPCError({ code: 'UNAUTHORIZED' });
22+
return user;
23+
},
24+
};
25+
}
26+
27+
export function makeCaller<T>(createCaller: (ctx: Context) => T, user: User) {
28+
return createCaller(makeContext(user));
29+
}

src/routes/+layout.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { faDiscord, faGithub, faTwitch } from '@fortawesome/free-brands-svg-icons';
1414
import {
1515
faBars,
16+
faGear,
1617
faKeyboard,
1718
faPlus,
1819
faRss,
@@ -231,6 +232,18 @@
231232
Profile</a
232233
>
233234
</CoolTextOnHover>
235+
<CoolTextOnHover>
236+
<a
237+
on:click={close}
238+
href="/settings"
239+
class="px-4 py-2 flex gap-2 items-center"
240+
>
241+
<div class="force-white-text">
242+
<Fa icon={faGear} class="text-base-100" />
243+
</div>
244+
Settings
245+
</a>
246+
</CoolTextOnHover>
234247
<CoolTextOnHover>
235248
<button class="px-4 py-2 flex gap-2 items-center" on:click={logout}>
236249
<div class="force-white-text">
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { verifyToken } from '$lib/server/auth/services';
2+
import { redirect } from '@sveltejs/kit';
3+
import type { PageServerLoad, PageServerLoadEvent } from './$types';
4+
5+
export const load: PageServerLoad = async (event: PageServerLoadEvent) => {
6+
const user = verifyToken(event.cookies);
7+
if (!user) {
8+
throw redirect(302, '/');
9+
}
10+
return { user };
11+
};

0 commit comments

Comments
 (0)