From 9320adb6cd1f8448e7e7b4f5dfb270af469d90fb Mon Sep 17 00:00:00 2001 From: Pascal Syma Date: Mon, 4 Aug 2025 20:23:19 +0200 Subject: [PATCH] Add login and register --- app/composables/useValidation.ts | 74 ++++++++++++++ app/lang/de.json | 26 ++++- app/lang/en.json | 26 ++++- app/pages/login.vue | 131 +++++++++++++++++++++---- app/pages/register.vue | 163 +++++++++++++++++++++++++++++++ bun.lock | 3 + package.json | 1 + prisma/seed/hunt.ts | 6 ++ server/api/auth/login.post.ts | 46 +++++++++ server/api/auth/register.post.ts | 49 ++++++++++ shared/utils/name.ts | 20 ++++ 11 files changed, 524 insertions(+), 21 deletions(-) create mode 100644 app/composables/useValidation.ts create mode 100644 app/pages/register.vue create mode 100644 server/api/auth/login.post.ts create mode 100644 server/api/auth/register.post.ts create mode 100644 shared/utils/name.ts diff --git a/app/composables/useValidation.ts b/app/composables/useValidation.ts new file mode 100644 index 0000000..b3c8858 --- /dev/null +++ b/app/composables/useValidation.ts @@ -0,0 +1,74 @@ +export type DateInputDate = Date | string | number; +export type DateInputRange = { + start?: T | null; + end?: T | null; +}; +export type DateInputModelValue = + | DateInputDate + | DateInputDate[] + | DateInputRange + | 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 { + return ( + !!model && + typeof model === 'object' && + (model as DateInputRange)?.end !== undefined && + (model as DateInputRange)?.start !== undefined + ); +} + +function isDateModel(model: DateInputModelValue): model is DateInputDate { + return !!model && !isNaN(new Date(model as any).getTime()); +} diff --git a/app/lang/de.json b/app/lang/de.json index ba0d8f1..1f1c96c 100644 --- a/app/lang/de.json +++ b/app/lang/de.json @@ -6,7 +6,19 @@ }, "auth": { "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": { "title": "{title} {'|'} PicHunt", @@ -20,6 +32,18 @@ "default": "Unbekannter Fehler", "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": { "search": "Search", "noOptions": "Items not found", diff --git a/app/lang/en.json b/app/lang/en.json index c656215..f87e59c 100644 --- a/app/lang/en.json +++ b/app/lang/en.json @@ -6,7 +6,19 @@ }, "auth": { "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": { "title": "{title} {'|'} PicHunt", @@ -20,6 +32,18 @@ "default": "Unkown error", "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": { "search": "Search", "noOptions": "Items not found", diff --git a/app/pages/login.vue b/app/pages/login.vue index 98d9b53..1d8290c 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -1,37 +1,130 @@ diff --git a/app/pages/register.vue b/app/pages/register.vue new file mode 100644 index 0000000..804c8bc --- /dev/null +++ b/app/pages/register.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/bun.lock b/bun.lock index c7b7257..35b8e12 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "nuxt-auth-utils": "^0.5.22", "pinia": "^3.0.3", "slugify": "^1.6.6", + "unique-names-generator": "^4.7.1", "vue": "^3.5.18", "vue-router": "^4.5.1", "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=="], + "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=="], "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], diff --git a/package.json b/package.json index f350dde..ba5adaf 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "nuxt-auth-utils": "^0.5.22", "pinia": "^3.0.3", "slugify": "^1.6.6", + "unique-names-generator": "^4.7.1", "vue": "^3.5.18", "vue-router": "^4.5.1", "zod": "^4.0.0" diff --git a/prisma/seed/hunt.ts b/prisma/seed/hunt.ts index e1e1e8e..1480b6c 100644 --- a/prisma/seed/hunt.ts +++ b/prisma/seed/hunt.ts @@ -1,4 +1,5 @@ import { PrismaClient } from '@prisma/client'; +import { hash } from 'argon2'; import { randomUUID } from 'node:crypto'; const prisma = new PrismaClient(); @@ -6,6 +7,7 @@ const prisma = new PrismaClient(); async function main() { await prisma.$transaction(async (tx) => { // region Users + const password = await hash('12345678'); const admin = await tx.user.upsert({ where: { email: 'admin@mail.com' }, update: {}, @@ -13,6 +15,7 @@ async function main() { email: 'admin@mail.com', name: 'Admin user', role: 'ADMIN', + password, emailConfirmedAt: new Date() } }); @@ -23,6 +26,7 @@ async function main() { email: 'team@mail.com', name: 'Hunt member', role: 'ADMIN', + password, emailConfirmedAt: new Date() } }); @@ -34,6 +38,7 @@ async function main() { email: 'user@mail.com', name: 'User', role: 'USER', + password, emailConfirmedAt: new Date() } }); @@ -44,6 +49,7 @@ async function main() { email: 'member@mail.com', name: 'Team Member', role: 'USER', + password, emailConfirmedAt: new Date() } }); diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts new file mode 100644 index 0000000..3de7a24 --- /dev/null +++ b/server/api/auth/login.post.ts @@ -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 } }; +}); diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts new file mode 100644 index 0000000..f626def --- /dev/null +++ b/server/api/auth/register.post.ts @@ -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 } }; +}); diff --git a/shared/utils/name.ts b/shared/utils/name.ts new file mode 100644 index 0000000..9b2dc55 --- /dev/null +++ b/shared/utils/name.ts @@ -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: '' }); +}