Skip to content

Commit b1b9933

Browse files
authored
(feat) 100: account page (#101)
* move utils * add delete account * add profile page with delete account functionality * adds integration tests for change password functions * reset change password inputs * return on password mismatch * throwing error instead of returning false or number, pr feedback * rename file to page-title * pr improvements * split up component * refactoring, use form actions instead of endpoints * fix validation not highlighting errors * return error message
1 parent 8fd3357 commit b1b9933

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+470
-70
lines changed

components.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99
"aliases": {
1010
"components": "$components",
11-
"utils": "$utils"
11+
"utils": "$lib/utils/shadcn"
1212
},
1313
"typescript": true
1414
}

services/src/user/user-repository.integration.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, it } from 'vitest'
2-
import { createUser, getUserById } from './user-repository'
2+
import { changeUserPasswordById, createUser, deleteUserById, getUserById } from './user-repository'
33
import { runMigration } from '../db/database-migration-util'
44
import { db } from '../db/database'
55
import type { SelectableUser, UserCreationParams } from './user'
@@ -46,4 +46,55 @@ describe('User Repository', () => {
4646
await expect(getUserById(-1)).rejects.toThrow()
4747
})
4848
})
49+
50+
describe('deleteUser', () => {
51+
it('should delete a user by ID', async () => {
52+
await createUser(userCreationObject)
53+
54+
const createdUser = await db.selectFrom('users').select('id').executeTakeFirstOrThrow()
55+
56+
await expect(deleteUserById(createdUser.id)).resolves.not.toThrowError()
57+
})
58+
59+
it('should throw an error when the user not exists', async () => {
60+
await expect(deleteUserById(512)).rejects.toThrowError()
61+
})
62+
63+
it('should only delete the given user when multiple users exist', async () => {
64+
const user1 = await createUser(userCreationObject)
65+
const user2 = await createUser({ ...userCreationObject, email: 'another@user.com' })
66+
67+
await expect(deleteUserById(user1.id)).resolves.not.toThrowError()
68+
69+
const fetchedUser2 = await getUserById(user2.id)
70+
expect(fetchedUser2).toEqual(user2)
71+
})
72+
})
73+
74+
describe('changeUserPasswordById', () => {
75+
it('should correctly update users password', async () => {
76+
await createUser(userCreationObject)
77+
78+
const createdUser = await db
79+
.selectFrom('users')
80+
.select(['id', 'password_hash'])
81+
.executeTakeFirstOrThrow()
82+
83+
await expect(
84+
changeUserPasswordById(createdUser.id, 'new-password')
85+
).resolves.not.toThrowError()
86+
87+
const userAfterPasswordChange = await db
88+
.selectFrom('users')
89+
.select(['id', 'password_hash'])
90+
.executeTakeFirstOrThrow()
91+
92+
expect(createdUser.id).toEqual(userAfterPasswordChange.id)
93+
expect(createdUser.password_hash).not.toEqual(userAfterPasswordChange.password_hash)
94+
})
95+
96+
it('should throw an error when trying to update a password for a user that does not exist', async () => {
97+
await expect(changeUserPasswordById(51245, 'new-password')).rejects.toThrowError()
98+
})
99+
})
49100
})

services/src/user/user-repository.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,22 @@ export function getUserByEmail(email: string): Promise<SelectableUser> {
2424
.where('email', '==', email)
2525
.executeTakeFirstOrThrow(() => new Error('User not found'))
2626
}
27+
28+
export async function deleteUserById(id: number): Promise<void> {
29+
const result = await db
30+
.deleteFrom('users')
31+
.where('id', '==', id)
32+
.executeTakeFirstOrThrow(() => new Error(`Could not delete user with id ${id}`))
33+
34+
if (!result.numDeletedRows) throw new Error('Could not delete any user')
35+
}
36+
37+
export async function changeUserPasswordById(id: number, passwordHash: string): Promise<void> {
38+
const result = await db
39+
.updateTable('users')
40+
.set({ password_hash: passwordHash })
41+
.where('users.id', '==', id)
42+
.executeTakeFirstOrThrow(() => new Error(`Could not update users password with id ${id}`))
43+
44+
if (!result.numUpdatedRows) throw new Error('Could not update users password')
45+
}
Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, it } from 'vitest'
2-
import { getUser } from './user-service'
2+
import { changeUserPassword, deleteUser, getUser } from './user-service'
33
import { createUser } from './user-repository'
44
import { runMigration } from '../db/database-migration-util'
55
import { db } from '../db/database'
@@ -11,16 +11,18 @@ beforeEach(async () => {
1111
})
1212

1313
describe('User Service Integration', () => {
14+
const newUserPlainPassword = 'securepassword'
15+
16+
const newUser = {
17+
email: 'integration@test.com',
18+
first_name: 'Integration',
19+
last_name: 'Test',
20+
password_hash: hash(newUserPlainPassword),
21+
role: 'user'
22+
}
23+
1424
describe('getUser', () => {
1525
it('should return a user when found', async () => {
16-
const newUser = {
17-
email: 'integration@test.com',
18-
first_name: 'Integration',
19-
last_name: 'Test',
20-
password_hash: hash('securepassword'),
21-
role: 'user'
22-
}
23-
2426
const createdUser = await createUser(newUser)
2527

2628
const result = await getUser(createdUser.id)
@@ -34,24 +36,60 @@ describe('User Service Integration', () => {
3436
})
3537
})
3638

37-
it('should throw an error when user is not found', async () => {
38-
await expect(getUser(999)).rejects.toThrow('User not found')
39+
it('should return undefined when user is not found', async () => {
40+
await expect(getUser(999)).resolves.toEqual(undefined)
3941
})
4042

4143
it('should not return the password hash', async () => {
42-
const newUser = {
43-
email: 'integration@test.com',
44-
first_name: 'Integration',
45-
last_name: 'Test',
46-
password_hash: hash('securepassword'),
47-
role: 'user'
48-
}
49-
5044
const createdUser = await createUser(newUser)
5145

5246
const result = await getUser(createdUser.id)
5347

5448
expect(result).not.toHaveProperty('password_hash')
5549
})
5650
})
51+
52+
describe('deleteUser', () => {
53+
it('should correctly delete an existing user', async () => {
54+
const createdUser = await createUser(newUser)
55+
56+
await expect(deleteUser(createdUser.id)).resolves.not.toThrowError()
57+
})
58+
59+
it('should return false when trying to delete a non-existing-user', async () => {
60+
await expect(deleteUser(5123)).rejects.toThrowError()
61+
})
62+
})
63+
64+
describe('changeUserPassword', () => {
65+
it('should correctly update a users password on valid input', async () => {
66+
const createdUser = await createUser(newUser)
67+
68+
const newPassword = 'new-secure-password-123'
69+
const confirmPassword = newPassword
70+
71+
await expect(
72+
changeUserPassword(createdUser.id, {
73+
currentPassword: newUserPlainPassword,
74+
newPassword,
75+
confirmPassword
76+
})
77+
).resolves.not.toThrowError()
78+
})
79+
80+
it('should throw an error when current password hash does not match', async () => {
81+
const createdUser = await createUser(newUser)
82+
83+
const newPassword = 'new-secure-password-123'
84+
const confirmPassword = newPassword
85+
86+
await expect(
87+
changeUserPassword(createdUser.id, {
88+
currentPassword: 'wrong-current-password',
89+
newPassword,
90+
confirmPassword
91+
})
92+
).rejects.toThrowError('Invalid password')
93+
})
94+
})
5795
})

services/src/user/user-service.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1+
// TODO: Investigate why vitest integration tests doesn't work with path aliases
2+
//import { compare, hash } from 'services/crypto/hash'
3+
import { compare, hash } from '../crypto/hash'
4+
import type { ChangePasswordPayload } from '$components/container/profile/schema'
15
import { omit } from '../util/omit'
26
import type { SelectableUser, User } from './user'
3-
import { getUserById } from './user-repository'
7+
import { changeUserPasswordById, deleteUserById, getUserById } from './user-repository'
48

5-
export async function getUser(id: number): Promise<User> {
6-
const user = await getUserById(id)
9+
export async function getUser(id: number): Promise<User | undefined> {
10+
let user: SelectableUser
11+
try {
12+
user = await getUserById(id)
13+
} catch (e: unknown) {
14+
return undefined
15+
}
716

817
return convertUserToNonAuthUser(user)
918
}
@@ -13,3 +22,24 @@ function convertUserToNonAuthUser(user: SelectableUser): User {
1322

1423
return nonAuthUser as User
1524
}
25+
26+
export async function deleteUser(id: number): Promise<void> {
27+
await deleteUserById(id)
28+
// TODO: add business logic to handle projects with or without other members
29+
}
30+
31+
export async function changeUserPassword(
32+
id: number,
33+
changePasswordPayload: ChangePasswordPayload
34+
): Promise<void> {
35+
const user = await getUserById(id)
36+
37+
const passwordMatches = compare(changePasswordPayload.currentPassword, user.password_hash)
38+
if (!passwordMatches) {
39+
throw new Error('Invalid password')
40+
}
41+
42+
// TODO: add a password validation on client, display password strength requirement
43+
const newPasswordHash = hash(changePasswordPayload.newPassword)
44+
await changeUserPasswordById(id, newPasswordHash)
45+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<script lang="ts">
2+
import { buttonVariants } from '$components/ui/button'
3+
import * as Dialog from '$components/ui/dialog'
4+
import * as Form from '$components/ui/form'
5+
import { KeyRound } from 'lucide-svelte'
6+
import { page } from '$app/stores'
7+
import { toast } from 'svelte-sonner'
8+
import Input from '$components/ui/input/input.svelte'
9+
import { changePasswordSchema } from './schema'
10+
import { type Infer, type SuperValidated, superForm } from 'sveltekit-superforms'
11+
import { zodClient } from 'sveltekit-superforms/adapters'
12+
13+
export let data: SuperValidated<Infer<typeof changePasswordSchema>>
14+
15+
const form = superForm(data, {
16+
validators: zodClient(changePasswordSchema),
17+
async onUpdated({ form }) {
18+
if (form.message) {
19+
if ($page.status >= 400) {
20+
toast.error(form.message)
21+
} else {
22+
toast.success(form.message)
23+
open = false
24+
}
25+
}
26+
}
27+
})
28+
29+
const { form: formData, enhance } = form
30+
31+
let open: boolean
32+
</script>
33+
34+
<div class="flex flex-col gap-1">
35+
<h3 class="text-xl font-medium">Credentials</h3>
36+
<p class="text-muted-foreground">Update your password</p>
37+
38+
<div class="flex">
39+
<Dialog.Root bind:open>
40+
<Dialog.Trigger class={buttonVariants({ variant: 'outline' })}>
41+
<KeyRound class="mr-2 h-5 w-5" />Edit password
42+
</Dialog.Trigger>
43+
44+
<Dialog.Content class="sm:max-w-[425px]">
45+
<form method="POST" action="?/changePassword" use:enhance>
46+
<Dialog.Header>
47+
<Dialog.Title>Edit password</Dialog.Title>
48+
<!-- <Dialog.Description></Dialog.Description> -->
49+
</Dialog.Header>
50+
51+
<div class="my-3 flex flex-col gap-3">
52+
<Form.Field {form} name="currentPassword">
53+
<Form.Control let:attrs>
54+
<Form.Label>Current Password</Form.Label>
55+
<Input
56+
type="password"
57+
data-testid="current-password"
58+
placeholder="Enter current password"
59+
{...attrs}
60+
bind:value={$formData.currentPassword}
61+
/>
62+
</Form.Control>
63+
<Form.FieldErrors />
64+
</Form.Field>
65+
66+
<Form.Field {form} name="newPassword">
67+
<Form.Control let:attrs>
68+
<Form.Label>New Password</Form.Label>
69+
<Input
70+
type="password"
71+
data-testid="new-password"
72+
placeholder="Enter new password"
73+
{...attrs}
74+
bind:value={$formData.newPassword}
75+
/>
76+
</Form.Control>
77+
<Form.FieldErrors />
78+
</Form.Field>
79+
80+
<Form.Field {form} name="confirmPassword">
81+
<Form.Control let:attrs>
82+
<Form.Label>Confirm Password</Form.Label>
83+
<Input
84+
type="password"
85+
data-testid="confirm-password"
86+
placeholder="Confirm password"
87+
{...attrs}
88+
bind:value={$formData.confirmPassword}
89+
/>
90+
</Form.Control>
91+
<Form.FieldErrors />
92+
</Form.Field>
93+
</div>
94+
95+
<Dialog.Footer>
96+
<Form.Button variant="default">Update Password</Form.Button>
97+
</Dialog.Footer>
98+
</form>
99+
</Dialog.Content>
100+
</Dialog.Root>
101+
</div>
102+
</div>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script lang="ts">
2+
import { enhance } from '$app/forms'
3+
import { buttonVariants } from '$components/ui/button'
4+
import Button from '$components/ui/button/button.svelte'
5+
import * as Dialog from '$components/ui/dialog'
6+
import * as Form from '$components/ui/form'
7+
import { LogOut, Trash } from 'lucide-svelte'
8+
</script>
9+
10+
<div class="rounded border-2 border-dashed border-destructive">
11+
<div class="m-2 flex flex-col gap-1">
12+
<h3 class="text-xl font-medium text-destructive">Danger Zone</h3>
13+
<p class="text-muted-foreground">
14+
Deleting the profile would remove all your data and your current subscription.
15+
</p>
16+
17+
<div class="flex gap-2">
18+
<Dialog.Root>
19+
<Dialog.Trigger class={buttonVariants({ variant: 'destructive' })}>
20+
<Trash class="mr-2 h-5 w-5" />Delete your account
21+
</Dialog.Trigger>
22+
<Dialog.Content class="sm:max-w-[425px]">
23+
<form method="POST" action="?/deleteProfile" use:enhance>
24+
<Dialog.Header>
25+
<Dialog.Title>Delete profile</Dialog.Title>
26+
<Dialog.Description>Are you sure you want to delete your profile?</Dialog.Description>
27+
</Dialog.Header>
28+
29+
<Dialog.Footer class="mt-2">
30+
<Form.Button variant="destructive">
31+
Delete now <Trash class="ml-2 h-5 w-5" />
32+
</Form.Button>
33+
</Dialog.Footer>
34+
</form>
35+
</Dialog.Content>
36+
</Dialog.Root>
37+
<Button href="/logout" variant="outline"><LogOut class="mr-2 h-4 w-4" /> Logout</Button>
38+
</div>
39+
</div>
40+
</div>

0 commit comments

Comments
 (0)