Browse Source

Feat: Account (#1645)

* be able to change name, email

* be able to change password

* consistent naming

zod is a schema not a type

* use transaction instance

* add zod strings
pull/1657/head
Bernd Storath 6 months ago
committed by GitHub
parent
commit
975524d6b4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 19
      src/app/components/form/PasswordField.vue
  2. 2
      src/app/components/form/TextField.vue
  3. 8
      src/app/pages/clients/[id].vue
  4. 206
      src/app/pages/me.vue
  5. 8
      src/i18n/locales/en.json
  6. 13
      src/server/api/me/index.post.ts
  7. 13
      src/server/api/me/password.post.ts
  8. 4
      src/server/api/setup/4.post.ts
  9. 4
      src/server/api/setup/5.post.ts
  10. 48
      src/server/database/repositories/user/service.ts
  11. 35
      src/server/database/repositories/user/types.ts
  12. 2
      src/server/database/repositories/userConfig/types.ts

19
src/app/components/form/PasswordField.vue

@ -0,0 +1,19 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model.trim="data"
:name="id"
type="password"
:autocomplete="autocomplete"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string; autocomplete: string }>();
const data = defineModel<string>();
</script>

2
src/app/components/form/TextField.vue

@ -4,7 +4,7 @@
</Label> </Label>
<input <input
:id="id" :id="id"
v-model="data" v-model.trim="data"
:name="id" :name="id"
type="text" type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"

8
src/app/pages/clients/[id].vue

@ -10,7 +10,7 @@
<FormHeading> <FormHeading>
{{ $t('me.sectionGeneral') }} {{ $t('me.sectionGeneral') }}
</FormHeading> </FormHeading>
<FormTextField id="name" v-model.trim="data.name" label="Name" /> <FormTextField id="name" v-model="data.name" label="Name" />
<FormSwitchField <FormSwitchField
id="enabled" id="enabled"
v-model="data.enabled" v-model="data.enabled"
@ -18,7 +18,7 @@
/> />
<FormDateField <FormDateField
id="expiresAt" id="expiresAt"
v-model.trim="data.expiresAt" v-model="data.expiresAt"
label="Expire Date" label="Expire Date"
/> />
</FormGroup> </FormGroup>
@ -26,12 +26,12 @@
<FormHeading>Address</FormHeading> <FormHeading>Address</FormHeading>
<FormTextField <FormTextField
id="ipv4Address" id="ipv4Address"
v-model.trim="data.ipv4Address" v-model="data.ipv4Address"
label="IPv4" label="IPv4"
/> />
<FormTextField <FormTextField
id="ipv6Address" id="ipv6Address"
v-model.trim="data.ipv6Address" v-model="data.ipv6Address"
label="IPv6" label="IPv6"
/> />
</FormGroup> </FormGroup>

206
src/app/pages/me.vue

@ -5,116 +5,126 @@
<PanelHeadTitle :text="$t('pages.me')" /> <PanelHeadTitle :text="$t('pages.me')" />
</PanelHead> </PanelHead>
<PanelBody class="dark:text-neutral-200"> <PanelBody class="dark:text-neutral-200">
<section class="grid grid-cols-1 gap-4 md:grid-cols-2"> <FormElement @submit.prevent="submit">
<h4 class="col-span-full py-6 text-2xl"> <FormGroup>
{{ $t('me.sectionGeneral') }} <FormHeading>{{ $t('me.sectionGeneral') }}</FormHeading>
</h4> <FormTextField id="name" v-model="name" :label="$t('name')" />
<Label <FormTextField id="email" v-model="email" :label="$t('email')" />
for="username" <FormActionField type="submit" :label="$t('save')" />
class="font-semibold md:align-middle md:leading-10" </FormGroup>
> </FormElement>
{{ $t('username') }} <FormElement @submit.prevent="updatePassword">
</Label> <FormGroup>
<input <FormHeading>{{ $t('me.sectionPassword') }}</FormHeading>
id="username" <FormPasswordField
v-model.trim="username" id="current-password"
type="text" v-model="currentPassword"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" autocomplete="current-password"
/> :label="$t('currentPassword')"
<Label for="name" class="font-semibold md:align-middle md:leading-10"> />
{{ $t('name') }} <FormPasswordField
</Label> id="new-password"
<input v-model="newPassword"
id="name" autocomplete="new-password"
v-model.trim="name" :label="$t('setup.newPassword')"
type="text" />
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400" <FormPasswordField
/> id="confirm-password"
<Label v-model="confirmPassword"
for="email" autocomplete="new-password"
class="font-semibold md:align-middle md:leading-10" :label="$t('confirmPassword')"
> />
{{ $t('email') }} <FormActionField type="submit" :label="$t('updatePassword')" />
</Label> </FormGroup>
<input </FormElement>
id="email"
v-model.trim="email"
type="email"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
<div class="col-span-full">
<BaseButton @click="submit">{{ $t('save') }}</BaseButton>
</div>
</section>
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
<h4 class="col-span-full py-6 text-2xl">
{{ $t('me.sectionPassword') }}
</h4>
<Label
for="current-password"
class="font-semibold md:align-middle md:leading-10"
>
{{ $t('currentPassword') }}
</Label>
<input
id="current-password"
v-model.trim="currentPassword"
type="password"
autocomplete="current-password"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
<Label
for="new-password"
class="font-semibold md:align-middle md:leading-10"
>
{{ $t('setup.newPassword') }}
</Label>
<input
id="new-password"
v-model.trim="newPassword"
type="password"
autocomplete="new-password"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
<Label
for="confirm-password"
class="font-semibold md:align-middle md:leading-10"
>
{{ $t('confirmPassword') }}
</Label>
<input
id="confirm-password"
v-model.trim="confirmPassword"
type="password"
autocomplete="new-password"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
<div class="col-span-full">
<BaseButton @click="updatePassword">{{
$t('updatePassword')
}}</BaseButton>
</div>
</section>
</PanelBody> </PanelBody>
</Panel> </Panel>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { FetchError } from 'ofetch';
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.update(); authStore.update();
const toast = useToast();
const username = ref(authStore.userData?.username);
const name = ref(authStore.userData?.name); const name = ref(authStore.userData?.name);
const email = ref(authStore.userData?.email);
// TODO: handle update password const rawEmail = ref(authStore.userData?.email);
const email = computed({
get: () => rawEmail.value ?? undefined,
set: (value) => {
const temp = value?.trim() ?? null;
if (temp === '') {
rawEmail.value = null;
return;
}
rawEmail.value = temp;
return;
},
});
const currentPassword = ref(authStore.userData?.email); async function submit() {
const newPassword = ref(authStore.userData?.email); try {
const confirmPassword = ref(authStore.userData?.email); const res = await $fetch(`/api/me`, {
method: 'post',
body: {
name: name.value,
email: rawEmail.value,
},
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to update general');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof FetchError) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.data.message,
});
}
}
}
function submit() {} // TODO: handle update password
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
function updatePassword() {} async function updatePassword() {
try {
const res = await $fetch(`/api/me/password`, {
method: 'post',
body: {
currentPassword: currentPassword.value,
newPassword: newPassword.value,
confirmPassword: confirmPassword.value,
},
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to update password');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof FetchError) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.data.message,
});
}
}
}
</script> </script>

8
src/i18n/locales/en.json

@ -61,7 +61,13 @@
"passwordNumber": "Password must have at least 1 number", "passwordNumber": "Password must have at least 1 number",
"passwordSpecial": "Password must have at least 1 special character", "passwordSpecial": "Password must have at least 1 special character",
"remember": "Remember must be a valid boolean", "remember": "Remember must be a valid boolean",
"accept": "Please accept the condition" "accept": "Please accept the condition",
"name": "Name must be a valid string",
"nameMin": "Name must be at least 1 Character",
"email": "Email must be a valid string",
"emailMin": "Email must be at least 1 Character",
"emailInvalid": "Email must be a valid email",
"passwordMatch": "Passwords must match"
}, },
"userConfig": { "userConfig": {
"host": "Host must be a valid string", "host": "Host must be a valid string",

13
src/server/api/me/index.post.ts

@ -0,0 +1,13 @@
import { UserUpdateSchema } from '#db/repositories/user/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event, user }) => {
const { name, email } = await readValidatedBody(
event,
validateZod(UserUpdateSchema)
);
await Database.users.update(user.id, name, email);
return { success: true };
}
);

13
src/server/api/me/password.post.ts

@ -0,0 +1,13 @@
import { UserUpdatePasswordSchema } from '#db/repositories/user/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event, user }) => {
const { newPassword, currentPassword } = await readValidatedBody(
event,
validateZod(UserUpdatePasswordSchema)
);
await Database.users.updatePassword(user.id, currentPassword, newPassword);
return { success: true };
}
);

4
src/server/api/setup/4.post.ts

@ -1,9 +1,9 @@
import { UserSetupType } from '#db/repositories/user/types'; import { UserSetupSchema } from '#db/repositories/user/types';
export default defineSetupEventHandler(async ({ event }) => { export default defineSetupEventHandler(async ({ event }) => {
const { username, password } = await readValidatedBody( const { username, password } = await readValidatedBody(
event, event,
validateZod(UserSetupType, event) validateZod(UserSetupSchema, event)
); );
await Database.users.create(username, password); await Database.users.create(username, password);

4
src/server/api/setup/5.post.ts

@ -1,9 +1,9 @@
import { UserConfigSetupType } from '#db/repositories/userConfig/types'; import { UserConfigSetupSchema } from '#db/repositories/userConfig/types';
export default defineSetupEventHandler(async ({ event }) => { export default defineSetupEventHandler(async ({ event }) => {
const { host, port } = await readValidatedBody( const { host, port } = await readValidatedBody(
event, event,
validateZod(UserConfigSetupType, event) validateZod(UserConfigSetupSchema, event)
); );
await Database.userConfigs.updateHostPort('wg0', host, port); await Database.userConfigs.updateHostPort('wg0', host, port);
await Database.general.setSetupStep(0); await Database.general.setSetupStep(0);

48
src/server/database/repositories/user/service.ts

@ -14,6 +14,14 @@ function createPreparedStatement(db: DBType) {
where: eq(user.username, sql.placeholder('username')), where: eq(user.username, sql.placeholder('username')),
}) })
.prepare(), .prepare(),
update: db
.update(user)
.set({
name: sql.placeholder('name') as never as string,
email: sql.placeholder('email') as never as string,
})
.where(eq(user.id, sql.placeholder('id')))
.prepare(),
}; };
} }
@ -42,7 +50,11 @@ export class UserService {
const hash = await hashPassword(password); const hash = await hashPassword(password);
return this.#db.transaction(async (tx) => { return this.#db.transaction(async (tx) => {
const oldUser = await this.getByUsername(username); const oldUser = await tx.query.user
.findFirst({
where: eq(user.username, username),
})
.execute();
if (oldUser) { if (oldUser) {
throw new Error('User already exists'); throw new Error('User already exists');
@ -60,4 +72,38 @@ export class UserService {
}); });
}); });
} }
async update(id: ID, name: string, email: string | null) {
return this.#statements.update.execute({ id, name, email });
}
async updatePassword(id: ID, currentPassword: string, newPassword: string) {
const hash = await hashPassword(newPassword);
return this.#db.transaction(async (tx) => {
// get user again to avoid password changing while request
const txUser = await tx.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser) {
throw new Error('User not found');
}
const passwordValid = await isPasswordValid(
currentPassword,
txUser.password
);
if (!passwordValid) {
throw new Error('Invalid password');
}
await tx
.update(user)
.set({ password: hash })
.where(eq(user.id, id))
.execute();
});
}
} }

35
src/server/database/repositories/user/types.ts

@ -20,6 +20,18 @@ const password = z
const remember = z.boolean({ message: 'zod.user.remember' }); const remember = z.boolean({ message: 'zod.user.remember' });
const name = z
.string({ message: 'zod.user.name' })
.min(1, 'zod.user.nameMin')
.pipe(safeStringRefine);
const email = z
.string({ message: 'zod.user.email' })
.min(5, 'zod.user.emailMin')
.email({ message: 'zod.user.emailInvalid' })
.pipe(safeStringRefine)
.nullable();
export const UserLoginSchema = z.object( export const UserLoginSchema = z.object(
{ {
username: username, username: username,
@ -33,7 +45,7 @@ const accept = z.boolean().refine((val) => val === true, {
message: 'zod.user.accept', message: 'zod.user.accept',
}); });
export const UserSetupType = z.object( export const UserSetupSchema = z.object(
{ {
username: username, username: username,
password: password, password: password,
@ -41,3 +53,24 @@ export const UserSetupType = z.object(
}, },
{ message: objectMessage } { message: objectMessage }
); );
export const UserUpdateSchema = z.object(
{
name: name,
email: email,
},
{ message: objectMessage }
);
export const UserUpdatePasswordSchema = z
.object(
{
currentPassword: password,
newPassword: password,
confirmPassword: password,
},
{ message: objectMessage }
)
.refine((val) => val.newPassword === val.confirmPassword, {
message: 'zod.user.passwordMatch',
});

2
src/server/database/repositories/userConfig/types.ts

@ -9,7 +9,7 @@ const host = z
.min(1, 'zod.userConfig.hostMin') .min(1, 'zod.userConfig.hostMin')
.pipe(safeStringRefine); .pipe(safeStringRefine);
export const UserConfigSetupType = z.object({ export const UserConfigSetupSchema = z.object({
host: host, host: host,
port: PortSchema, port: PortSchema,
}); });

Loading…
Cancel
Save