Add updatable profile

This commit is contained in:
2025-08-14 21:31:48 +02:00
parent 9cb405be22
commit e84c022bcf
5 changed files with 386 additions and 2 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
- **Auth** - **Auth**
- [x] Flawless! Login (Email+Pw ohne bestätigung?) - [x] Flawless! Login (Email+Pw ohne bestätigung?)
- [x] Default random name - [x] Default random name
- [ ] Change display name - [x] Change display name
- **Hunt Admin** - **Hunt Admin**
- [x] Start/Stop - [x] Start/Stop
- [x] Add Reviews/Score - [x] Add Reviews/Score
+261 -1
View File
@@ -2,10 +2,270 @@
definePageMeta({ definePageMeta({
middleware: ['auth'] middleware: ['auth']
}); });
const router = useRouter();
const { t } = useI18n();
const { loggedIn, fetch } = useUserSession();
const localePath = useLocalePath();
const validation = useValidation();
watch(loggedIn, (isLoggedIn) => {
if (!isLoggedIn) {
router.push(localePath('/login'));
}
});
const { data, refresh, pending } = await useFetch('/api/auth/profile');
const form = reactive({
email: data.value!.email,
name: data.value!.name
});
const uploading = ref(false);
const changed = computed(
() => form.name !== data.value?.name || form.email !== data.value?.email
);
const profileError = ref('');
async function submitProfile() {
uploading.value = true;
profileError.value = '';
try {
await $fetch(`/api/auth/profile`, {
method: 'POST',
body: form
});
await refresh();
form.email = data.value!.email;
form.name = data.value!.name;
await fetch();
} catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 409) {
// ToDo: i18n
profileError.value = 'Email already exists!';
} else {
profileError.value = parseError(e);
}
}
uploading.value = false;
}
const password = reactive({
password: '',
old: ''
});
const passwordError = ref('');
async function submitPassword() {
uploading.value = true;
passwordError.value = '';
try {
await $fetch(`/api/auth/password`, {
method: 'POST',
body: password
});
password.password = '';
password.old = '';
} catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 404) {
// ToDo: i18n
passwordError.value = 'Old password incorrect!';
} else {
passwordError.value = parseError(e);
}
}
uploading.value = false;
}
</script> </script>
<template> <template>
<h1>profile</h1> <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<VaCard stripe stripe-color="success">
<VaCardTitle>Public profile</VaCardTitle>
<VaCardContent v-if="data">
<div class="flex flex-col gap-4">
<VaInput
label="Name"
v-model="form.name"
clearable
:clear-value="data.name"
clearable-icon="replay"
:disabled="uploading"
counter
max-length="265"
min-length="3"
:rules="[
validation.required(t('auth.name')),
validation.min(t('auth.name'), 3),
validation.max(t('auth.name'), 256)
]"
/>
<VaInput
label="Email"
type="email"
v-model="form.email"
clearable
:clear-value="data.email"
clearable-icon="replay"
:disabled="uploading"
:rules="[
validation.required(t('auth.email')),
validation.min(t('auth.email'), 3)
]"
/>
<VaAlert
v-if="profileError.length > 0"
color="danger"
class="w-full text-center"
>{{ t(profileError) }}</VaAlert
>
<VaButton
:loading="pending || uploading"
color="success"
:disabled="!changed || pending || uploading"
@click="submitProfile"
>Update</VaButton
>
</div>
</VaCardContent>
</VaCard>
<VaCard stripe stripe-color="danger">
<VaCardTitle>Change password</VaCardTitle>
<VaCardContent>
<div class="flex flex-col gap-4">
<VaValue v-slot="isPasswordVisible" :default-value="false">
<VaInput
v-model="password.old"
class="w-full"
:type="isPasswordVisible.value ? 'text' : 'password'"
label="Old password"
:placeholder="t('auth.password')"
autocomplete="current-password"
:minlength="8"
required
>
<template #prependInner>
<VaIcon name="key" color="secondary" />
</template>
<template #appendInner>
<VaButton
v-if="password.old"
preset="plain"
:title="
t(
isPasswordVisible.value
? 'auth.hidePassword'
: 'auth.showPassword'
)
"
@click="isPasswordVisible.value = !isPasswordVisible.value"
>
<VaIcon
:name="
isPasswordVisible.value ? 'visibility_off' : 'visibility'
"
color="primary"
/>
</VaButton>
</template>
</VaInput>
</VaValue>
<VaValue v-slot="isPasswordVisible" :default-value="false">
<VaInput
v-model="password.password"
class="w-full"
:type="isPasswordVisible.value ? 'text' : 'password'"
label="New password"
:placeholder="t('auth.password')"
autocomplete="current-password"
:minlength="8"
required
>
<template #prependInner>
<VaIcon name="key" color="secondary" />
</template>
<template #appendInner>
<VaButton
v-if="password.password"
preset="plain"
:title="
t(
isPasswordVisible.value
? 'auth.hidePassword'
: 'auth.showPassword'
)
"
@click="isPasswordVisible.value = !isPasswordVisible.value"
>
<VaIcon
:name="
isPasswordVisible.value ? 'visibility_off' : 'visibility'
"
color="primary"
/>
</VaButton>
</template>
</VaInput>
</VaValue>
<VaAlert
v-if="passwordError.length > 0"
color="danger"
class="w-full text-center"
>{{ t(profileError) }}</VaAlert
>
<VaButton
:loading="pending || uploading"
color="success"
:disabled="
password.password.length < 8 ||
password.old.length < 8 ||
pending ||
uploading
"
@click="submitPassword"
>Update</VaButton
>
</div>
</VaCardContent>
</VaCard>
<VaCard stripe stripe-color="primary">
<VaCardTitle>Uploaded pictures</VaCardTitle>
<VaCardContent>
<VaCarousel
v-if="data"
stateful
:items="data.uploadedFiles"
arrows
indicators
lazy
height="50vh"
indicator-trigger="hover"
>
<template #default="{ item }"
><ClickableImage
:src="item.url"
:title="`${item.answer.quest.title} — ${item.answer.quest.hunt.name}`"
/>
<NuxtLink
:to="
$localePath(
`/hunt/${sluggy(item.answer.quest.hunt)}/${sluggy(item.answer.quest)}`
)
"
class="absolute top-2 mx-auto rounded bg-gray-200/75 px-2 py-1 text-center text-xl hover:underline"
>
{{ item.answer.quest.title }}
{{ item.answer.quest.hunt.name }}
</NuxtLink></template
>
</VaCarousel>
</VaCardContent>
</VaCard>
</div>
</template> </template>
<style scoped></style> <style scoped></style>
+44
View File
@@ -0,0 +1,44 @@
import { hash, verify } from 'argon2';
import { useValidatedBody, z } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event);
const { old, password } = await useValidatedBody(
event,
z.object({
password: z.string().min(8),
old: z.string().min(8)
})
);
const dbUser = await prisma.user.findUniqueOrThrow({
where: {
deletedAt: null,
id: user.id
},
select: {
id: true,
password: true
}
});
if (!(await verify(dbUser.password, old))) {
throw createError({ status: 404, statusText: 'Not found!' });
}
const passwordHash = await hash(password);
await prisma.user.update({
where: {
id: user.id
},
data: {
password: passwordHash
}
});
console.warn(`Successful changed password for user ${user.id}`);
return { ok: true };
});
+42
View File
@@ -0,0 +1,42 @@
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
return prisma.user.findUnique({
where: { id: user.user.id },
select: {
name: true,
email: true,
uploadedFiles: {
select: {
url: true,
createdAt: true,
answer: {
select: {
id: true,
quest: {
select: {
id: true,
title: true,
hunt: {
select: {
id: true,
name: true
}
}
}
}
}
}
},
where: {
NOT: {
answerId: null
}
}
}
}
});
});
+38
View File
@@ -0,0 +1,38 @@
import { useValidatedBody, z } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const data = await useValidatedBody(
event,
z.object({
name: z.string().min(3).max(256).trim(),
email: z.email().max(256).trim()
})
);
try {
const dbUser = await prisma.user.update({
where: {
id: user.user.id
},
data,
select: {
id: true,
name: true,
role: true
}
});
await setUserSession(event, {
user: dbUser,
loggedInAt: user.loggedInAt
});
} catch (error) {
throw createError({
status: 409,
statusText: 'User with that email already exists!'
});
}
return { ok: true };
});