Add login and register

This commit is contained in:
2025-08-04 20:23:19 +02:00
parent 8aa8be7e9f
commit 9320adb6cd
11 changed files with 524 additions and 21 deletions
+74
View File
@@ -0,0 +1,74 @@
export type DateInputDate = Date | string | number;
export type DateInputRange<T> = {
start?: T | null;
end?: T | null;
};
export type DateInputModelValue =
| DateInputDate
| DateInputDate[]
| DateInputRange<DateInputDate>
| undefined
| null
| string;
export default function () {
const { t } = useI18n();
return {
required(name: string) {
return (value: string) =>
(value && value.length > 0) || t('validation.required', { name });
},
max(name: string, max = 64) {
return (value: string) =>
(value && value.length <= max) ||
t('validation.max_length', { name, max });
},
min(name: string, min = 8) {
return (value: string) =>
(value && value.length >= min) ||
t('validation.min_length', { name, min });
},
dateRangeIsPast() {
return (model: DateInputModelValue) =>
(model && isRangeModel(model) && model.start && model.end
? new Date(model.start).getTime() < Date.now() &&
new Date(model.end).getTime() < Date.now()
: true) || t('validation.date_range_past');
},
dateIsPast() {
return (model: DateInputModelValue) =>
(model &&
isDateModel(model) &&
new Date(model).getTime() < Date.now()) ||
t('validation.date_range_past');
},
numeric(name: string) {
return (value: string) =>
(value &&
!isNaN(parseFloat(value)) &&
value?.split(' ').length === 1) ||
t('validation.numeric', { name });
},
numericOptional(name: string) {
return (value: string | undefined) =>
!value ||
(!isNaN(parseFloat(value)) && value?.split(' ').length === 1) ||
t('validation.numeric', { name });
}
} as const;
}
function isRangeModel(
model: DateInputModelValue
): model is DateInputRange<DateInputDate> {
return (
!!model &&
typeof model === 'object' &&
(model as DateInputRange<any>)?.end !== undefined &&
(model as DateInputRange<any>)?.start !== undefined
);
}
function isDateModel(model: DateInputModelValue): model is DateInputDate {
return !!model && !isNaN(new Date(model as any).getTime());
}
+25 -1
View File
@@ -6,7 +6,19 @@
}, },
"auth": { "auth": {
"login": "Anmelden", "login": "Anmelden",
"logout": "Abmelden" "login_instead": "Stattdessen anmelden",
"register": "Registrieren",
"register_instead": "Stattdessen registrieren",
"logout": "Abmelden",
"email": "E-Mail",
"password": "Passwort",
"name": "Name",
"accept": "Akzeptieren",
"showPassword": "Passwort einblenden",
"hidePassword": "Passwort ausblenden",
"reset": "Zurücksetzen",
"conflict": "Diese Email wird bereits verwendet, bitte verwende eine andere!",
"invalid": "Email oder Passwort sind falsch, bitte versuche es erneut!"
}, },
"layouts": { "layouts": {
"title": "{title} {'|'} PicHunt", "title": "{title} {'|'} PicHunt",
@@ -20,6 +32,18 @@
"default": "Unbekannter Fehler", "default": "Unbekannter Fehler",
"team_not_found": "Team nicht gefunden oder Passwort inkorrekt!" "team_not_found": "Team nicht gefunden oder Passwort inkorrekt!"
}, },
"validation": {
"max_length": "{name} darf maximal {max} Zeichen lang sein",
"min_length": "{name} muss mindestens {min} Zeichen lang sein",
"numeric": "{name} muss numerisch sein",
"min_array": "Du brauchst mindestens {min} {name}",
"required": "{name} ist erforderlich",
"invalid": "Ungültiges Format",
"taken": "Bereits vergeben",
"date_range_past": "Zeitraum darf nicht in der Zukunft liegen",
"date_past": "Zeit darf nicht in der Zukunft liegen",
"confirm": "Sind Sie sich sicher?"
},
"vuestic": { "vuestic": {
"search": "Search", "search": "Search",
"noOptions": "Items not found", "noOptions": "Items not found",
+25 -1
View File
@@ -6,7 +6,19 @@
}, },
"auth": { "auth": {
"login": "Login", "login": "Login",
"logout": "Logout" "login_instead": "Login instead",
"register": "Register",
"register_instead": "Register instead",
"logout": "Logout",
"email": "Email",
"password": "Password",
"name": "Name",
"accept": "Accept",
"showPassword": "Show password",
"hidePassword": "Hide password",
"reset": "Reset",
"conflict": "This e-mail is already in use, please use a different one!",
"invalid": "E-mail or password is invalid, please try again!"
}, },
"layouts": { "layouts": {
"title": "{title} {'|'} PicHunt", "title": "{title} {'|'} PicHunt",
@@ -20,6 +32,18 @@
"default": "Unkown error", "default": "Unkown error",
"team_not_found": "Team not found or password incorrect!" "team_not_found": "Team not found or password incorrect!"
}, },
"validation": {
"max_length": "{name} has to be max {max} chars long",
"min_length": "{name} has to at least {min} chars long",
"numeric": "{name} need to be numeric",
"min_array": "You need at least {min} {name}",
"required": "{name} is required",
"invalid": "Invalid format",
"taken": "Already taken",
"date_range_past": "Date range cannot be in the future",
"date_past": "Date cannot be in the future",
"confirm": "Are you sure?"
},
"vuestic": { "vuestic": {
"search": "Search", "search": "Search",
"noOptions": "Items not found", "noOptions": "Items not found",
+112 -19
View File
@@ -1,37 +1,130 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from '#app'; import { useForm } from 'vuestic-ui';
definePageMeta({ definePageMeta({
middleware: ['guest'] middleware: ['guest']
}); });
const { fetch } = useUserSession(); const { fetch } = useUserSession();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const localePath = useLocalePath();
// TODO: remove this lol const { isValid, resetValidation, validate } = useForm('formRef');
const { data } = await useFetch('/api/auth/test-list'); const validation = useValidation();
async function login(id: number) { const form = reactive({
await $fetch(`/api/auth/test?id=${id}`); email: '',
password: ''
});
const error = ref('');
async function submit() {
if (!validate()) {
return;
}
error.value = '';
try {
await $fetch('/api/auth/login', {
method: 'POST',
body: form
});
await fetch(); await fetch();
await router.push(localePath('/'));
await router.push( return;
(route.query.to } catch (e) {
? typeof route.query.to === 'string' if (e instanceof Error && 'statusCode' in e && e.statusCode === 404) {
? route.query.to error.value = 'auth.invalid';
: route.query.to[0] } else {
: '/') || '/' error.value = parseError(e);
); }
}
form.password = '';
resetValidation();
} }
</script> </script>
<template> <template>
<h1>login</h1> <h1 class="mb-4 text-center text-3xl">{{ $t('auth.login') }}</h1>
<div class="flex flex-col gap-2" v-if="data"> <div class="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5">
<VaButton v-for="user in data" :key="user.id" @click="login(user.id)" <VaForm
>Login as "{{ user.name }}" {{ user.email }}</VaButton ref="formRef"
tag="form"
class="flex flex-col items-baseline gap-6 sm:col-start-2 lg:col-start-3"
@submit.prevent="submit"
> >
<VaInput
v-model="form.email"
class="w-full"
type="email"
:label="$t('auth.email')"
autocomplete="email"
:rules="[validation.required($t('auth.email'))]"
:placeholder="$t('auth.email')"
required
>
<template #prependInner>
<VaIcon name="mail_outline" color="secondary" />
</template>
</VaInput>
<VaValue v-slot="isPasswordVisible" :default-value="false">
<VaInput
v-model="form.password"
class="w-full"
:type="isPasswordVisible.value ? 'text' : 'password'"
:label="$t('auth.password')"
:placeholder="$t('auth.password')"
:rules="[
validation.required($t('auth.password')),
validation.min($t('auth.password'), 8)
]"
autocomplete="current-password"
:minlength="8"
required
>
<template #prependInner>
<VaIcon name="key" color="secondary" />
</template>
<template #appendInner>
<VaButton
v-if="form.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="error.length > 0"
color="danger"
class="w-full text-center"
>{{ $t(error) }}</VaAlert
>
<VaButtonGroup>
<VaButton color="success" type="submit" :disabled="!isValid">{{
$t('auth.login')
}}</VaButton>
<VaButton
:to="$localePath('/register')"
border-color="primary"
preset="secondary"
>{{ $t('auth.register_instead') }}</VaButton
>
</VaButtonGroup>
</VaForm>
</div> </div>
</template> </template>
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { getRandomName } from '#shared/utils/name';
import { useForm } from 'vuestic-ui';
definePageMeta({
middleware: ['guest']
});
const { fetch } = useUserSession();
const router = useRouter();
const localePath = useLocalePath();
const { isValid, resetValidation, validate } = useForm('formRef');
const validation = useValidation();
const form = reactive({
name: getRandomName(),
email: '',
password: ''
});
const error = ref('');
async function submit() {
if (!validate()) {
return;
}
error.value = '';
try {
await $fetch('/api/auth/register', {
method: 'POST',
body: form
});
await fetch();
await router.push(localePath('/'));
return;
} catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 409) {
error.value = 'auth.conflict';
} else {
error.value = parseError(e);
}
}
form.password = '';
resetValidation();
}
</script>
<template>
<h1 class="mb-4 text-center text-3xl">{{ $t('auth.login') }}</h1>
<div class="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5">
<VaForm
ref="formRef"
tag="form"
class="flex flex-col items-baseline gap-6 sm:col-start-2 lg:col-start-3"
@submit.prevent="submit"
>
<VaInput
v-model="form.name"
class="w-full"
type="text"
autocomplete="name"
clearable
clear-value=""
:label="$t('auth.name')"
:placeholder="$t('auth.name')"
max-length="64"
min-length="5"
:rules="[
validation.required($t('auth.name')),
validation.max($t('auth.name'), 64),
validation.min($t('auth.name'), 5)
]"
required
>
<template #prependInner>
<VaIcon name="person" color="secondary" />
</template>
<template #appendInner>
<VaButton
icon="refresh"
preset="plain"
@click="form.name = getRandomName()"
/>
</template>
</VaInput>
<VaInput
v-model="form.email"
class="w-full"
type="email"
:label="$t('auth.email')"
autocomplete="email"
:rules="[validation.required($t('auth.email'))]"
:placeholder="$t('auth.email')"
required
>
<template #prependInner>
<VaIcon name="mail_outline" color="secondary" />
</template>
</VaInput>
<VaValue v-slot="isPasswordVisible" :default-value="false">
<VaInput
v-model="form.password"
class="w-full"
:type="isPasswordVisible.value ? 'text' : 'password'"
:label="$t('auth.password')"
:placeholder="$t('auth.password')"
:rules="[
validation.required($t('auth.password')),
validation.min($t('auth.password'), 8)
]"
autocomplete="current-password"
:minlength="8"
required
>
<template #prependInner>
<VaIcon name="key" color="secondary" />
</template>
<template #appendInner>
<VaButton
v-if="form.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="error.length > 0"
color="danger"
class="w-full text-center"
>{{ $t(error) }}</VaAlert
>
<VaButtonGroup>
<VaButton color="success" type="submit" :disabled="!isValid">{{
$t('auth.register')
}}</VaButton>
<VaButton
:to="$localePath('/login')"
border-color="primary"
preset="secondary"
>{{ $t('auth.login_instead') }}</VaButton
>
</VaButtonGroup>
</VaForm>
</div>
</template>
<style scoped></style>
+3
View File
@@ -17,6 +17,7 @@
"nuxt-auth-utils": "^0.5.22", "nuxt-auth-utils": "^0.5.22",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"unique-names-generator": "^4.7.1",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"zod": "^4.0.0", "zod": "^4.0.0",
@@ -1890,6 +1891,8 @@
"unimport": ["unimport@5.2.0", "", { "dependencies": { "acorn": "^8.15.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.1", "magic-string": "^0.30.17", "mlly": "^1.7.4", "pathe": "^2.0.3", "picomatch": "^4.0.3", "pkg-types": "^2.2.0", "scule": "^1.3.0", "strip-literal": "^3.0.0", "tinyglobby": "^0.2.14", "unplugin": "^2.3.5", "unplugin-utils": "^0.2.4" } }, "sha512-bTuAMMOOqIAyjV4i4UH7P07pO+EsVxmhOzQ2YJ290J6mkLUdozNhb5I/YoOEheeNADC03ent3Qj07X0fWfUpmw=="], "unimport": ["unimport@5.2.0", "", { "dependencies": { "acorn": "^8.15.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.1", "magic-string": "^0.30.17", "mlly": "^1.7.4", "pathe": "^2.0.3", "picomatch": "^4.0.3", "pkg-types": "^2.2.0", "scule": "^1.3.0", "strip-literal": "^3.0.0", "tinyglobby": "^0.2.14", "unplugin": "^2.3.5", "unplugin-utils": "^0.2.4" } }, "sha512-bTuAMMOOqIAyjV4i4UH7P07pO+EsVxmhOzQ2YJ290J6mkLUdozNhb5I/YoOEheeNADC03ent3Qj07X0fWfUpmw=="],
"unique-names-generator": ["unique-names-generator@4.7.1", "", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="],
+1
View File
@@ -25,6 +25,7 @@
"nuxt-auth-utils": "^0.5.22", "nuxt-auth-utils": "^0.5.22",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"unique-names-generator": "^4.7.1",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"zod": "^4.0.0" "zod": "^4.0.0"
+6
View File
@@ -1,4 +1,5 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { hash } from 'argon2';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -6,6 +7,7 @@ const prisma = new PrismaClient();
async function main() { async function main() {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// region Users // region Users
const password = await hash('12345678');
const admin = await tx.user.upsert({ const admin = await tx.user.upsert({
where: { email: 'admin@mail.com' }, where: { email: 'admin@mail.com' },
update: {}, update: {},
@@ -13,6 +15,7 @@ async function main() {
email: 'admin@mail.com', email: 'admin@mail.com',
name: 'Admin user', name: 'Admin user',
role: 'ADMIN', role: 'ADMIN',
password,
emailConfirmedAt: new Date() emailConfirmedAt: new Date()
} }
}); });
@@ -23,6 +26,7 @@ async function main() {
email: 'team@mail.com', email: 'team@mail.com',
name: 'Hunt member', name: 'Hunt member',
role: 'ADMIN', role: 'ADMIN',
password,
emailConfirmedAt: new Date() emailConfirmedAt: new Date()
} }
}); });
@@ -34,6 +38,7 @@ async function main() {
email: 'user@mail.com', email: 'user@mail.com',
name: 'User', name: 'User',
role: 'USER', role: 'USER',
password,
emailConfirmedAt: new Date() emailConfirmedAt: new Date()
} }
}); });
@@ -44,6 +49,7 @@ async function main() {
email: 'member@mail.com', email: 'member@mail.com',
name: 'Team Member', name: 'Team Member',
role: 'USER', role: 'USER',
password,
emailConfirmedAt: new Date() emailConfirmedAt: new Date()
} }
}); });
+46
View File
@@ -0,0 +1,46 @@
import { verify } from 'argon2';
import { useValidatedBody, z } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const { email, password } = await useValidatedBody(
event,
z.object({
email: z.email().min(5).trim(),
password: z.string().min(8)
})
);
const user = await prisma.user.findUnique({
where: {
deletedAt: null,
email
},
select: {
id: true,
password: true,
name: true,
role: true
}
});
if (!user || user.password.length <= 0) {
console.warn(`Failed login attempted: Unknown email: ${email}`);
throw createError({ status: 404, statusText: 'Not found!' });
}
if (!(await verify(user.password, password))) {
console.warn(`Failed login attempted: Wrong password: ${email}`);
throw createError({ status: 404, statusText: 'Not found!' });
}
console.warn(`Successful login attempted: ${email}`);
await setUserSession(event as any, {
user: {
id: user.id,
name: user.name,
role: user.role
},
loggedInAt: new Date()
});
return { success: true, user: { id: user.id } };
});
+49
View File
@@ -0,0 +1,49 @@
import { hash } from 'argon2';
import { useValidatedBody, z } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
await clearUserSession(event as any);
const { email, password, name } = await useValidatedBody(
event,
z.object({
email: z.email().min(5).trim(),
password: z.string().min(8),
name: z.string().min(5).max(64).trim()
})
);
const exists = await prisma.user.findUnique({
where: {
email
},
select: { id: true }
});
if (exists !== null) {
throw createError({
status: 409,
statusText: 'User with that email already exists!'
});
}
const passwordHash = await hash(password);
const user = await prisma.user.create({
data: {
password: passwordHash,
name,
email
},
select: { id: true, name: true, role: true }
});
console.warn(`Successful register: ${email}`);
await setUserSession(event, {
user: user
});
return { success: true, user: { id: user.id } };
});
+20
View File
@@ -0,0 +1,20 @@
import {
adjectives,
animals,
colors,
type Config,
uniqueNamesGenerator
} from 'unique-names-generator';
const nameConfig: Config = {
dictionaries: [adjectives, colors, animals],
separator: ' ',
style: 'capital'
};
export function getRandomName() {
return uniqueNamesGenerator(nameConfig);
}
export function getRandomUsername() {
return uniqueNamesGenerator({ ...nameConfig, separator: '' });
}