Refactor login functionality to use email instead of username. Update related tests, API routes, and database schema to support email-based authentication. Ensure consistency across login forms and queries.

This commit is contained in:
Robert Hajdu 2025-08-13 20:28:20 +02:00
parent d961c058dd
commit 6223e22c0d
7 changed files with 49 additions and 21 deletions

View file

@ -9,9 +9,9 @@ describe('Login tests', () => {
defaultCommandTimeout: 10000, defaultCommandTimeout: 10000,
}, },
() => { () => {
cy.getDataTest('input-username').find('input').as('inputUsername').click(); cy.getDataTest('input-username').find('input').as('inputEmail').click();
cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 }); cy.get('@inputEmail').type(Cypress.env('umami_email'), { delay: 0 });
cy.get('@inputUsername').click(); cy.get('@inputEmail').click();
cy.getDataTest('input-password') cy.getDataTest('input-password')
.find('input') .find('input')
.type(Cypress.env('umami_password'), { delay: 0 }); .type(Cypress.env('umami_password'), { delay: 0 });
@ -25,12 +25,12 @@ describe('Login tests', () => {
cy.getDataTest('button-submit').click(); cy.getDataTest('button-submit').click();
cy.contains(/Required/i).should('be.visible'); cy.contains(/Required/i).should('be.visible');
cy.getDataTest('input-username').find('input').as('inputUsername'); cy.getDataTest('input-username').find('input').as('inputEmail');
cy.get('@inputUsername').click(); cy.get('@inputEmail').click();
cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 }); cy.get('@inputEmail').type(Cypress.env('umami_email'), { delay: 0 });
cy.get('@inputUsername').click(); cy.get('@inputEmail').click();
cy.getDataTest('input-password').find('input').type('wrongpassword', { delay: 0 }); cy.getDataTest('input-password').find('input').type('wrongpassword', { delay: 0 });
cy.getDataTest('button-submit').click(); cy.getDataTest('button-submit').click();
cy.contains(/Incorrect username and\/or password./i).should('be.visible'); cy.contains(/Incorrect username and\/or password\./i).should('be.visible');
}); });
}); });

View file

@ -11,6 +11,8 @@ datasource db {
model User { model User {
id String @id @unique @map("user_id") @db.VarChar(36) id String @id @unique @map("user_id") @db.VarChar(36)
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
email String? @unique @db.VarChar(255)
emailVerified DateTime? @map("email_verified") @db.Timestamp(0)
password String @db.VarChar(60) password String @db.VarChar(60)
role String @map("role") @db.VarChar(50) role String @map("role") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183) logoUrl String? @map("logo_url") @db.VarChar(2183)

View file

@ -11,6 +11,8 @@ datasource db {
model User { model User {
id String @id @unique @map("user_id") @db.Uuid id String @id @unique @map("user_id") @db.Uuid
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
email String? @unique @db.VarChar(255)
emailVerified DateTime? @map("email_verified") @db.Timestamptz(6)
password String @db.VarChar(60) password String @db.VarChar(60)
role String @map("role") @db.VarChar(50) role String @map("role") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183) logoUrl String? @map("logo_url") @db.VarChar(2183)

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { checkPassword } from '@/lib/auth'; import { checkPassword } from '@/lib/auth';
import { createSecureToken } from '@/lib/jwt'; import { createSecureToken } from '@/lib/jwt';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { getUserByUsername } from '@/queries'; import { getUserByEmail } from '@/queries';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { saveAuth } from '@/lib/auth'; import { saveAuth } from '@/lib/auth';
@ -11,7 +11,7 @@ import { ROLES } from '@/lib/constants';
export async function POST(request: Request) { export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
username: z.string(), email: z.string().email(),
password: z.string(), password: z.string(),
}); });
@ -21,9 +21,9 @@ export async function POST(request: Request) {
return error(); return error();
} }
const { username, password } = body; const { email, password } = body;
const user = await getUserByUsername(username, { includePassword: true }); const user = await getUserByEmail(email, { includePassword: true });
if (!user || !checkPassword(password, user.password)) { if (!user || !checkPassword(password, user.password)) {
return unauthorized('message.incorrect-username-password'); return unauthorized('message.incorrect-username-password');
@ -41,6 +41,6 @@ export async function POST(request: Request) {
return json({ return json({
token, token,
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin }, user: { id, username: user.username, role, createdAt, isAdmin: role === ROLES.admin },
}); });
} }

View file

@ -20,7 +20,7 @@ export function LoginForm() {
const handleSubmit = async (data: any) => { const handleSubmit = async (data: any) => {
const res = await signIn('credentials', { const res = await signIn('credentials', {
username: data.username, email: data.email,
password: data.password, password: data.password,
redirect: false, redirect: false,
}); });
@ -39,13 +39,13 @@ export function LoginForm() {
</Icon> </Icon>
<div className={styles.title}>umami</div> <div className={styles.title}>umami</div>
<Form className={styles.form} onSubmit={handleSubmit}> <Form className={styles.form} onSubmit={handleSubmit}>
<FormRow label={formatMessage(labels.username)}> <FormRow label={formatMessage(labels.email)}>
<FormInput <FormInput
data-test="input-username" data-test="input-username"
name="username" name="email"
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
<TextField autoComplete="username" /> <TextField autoComplete="email" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.password)}> <FormRow label={formatMessage(labels.password)}>

View file

@ -1,7 +1,7 @@
import type { NextAuthOptions } from 'next-auth'; import type { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials'; import CredentialsProvider from 'next-auth/providers/credentials';
import { checkPassword } from '@/lib/auth'; import { checkPassword } from '@/lib/auth';
import { getUserByUsername } from '@/queries'; import { getUserByEmail } from '@/queries';
const AUTH_SECRET = process.env.NEXTAUTH_SECRET || process.env.APP_SECRET; const AUTH_SECRET = process.env.NEXTAUTH_SECRET || process.env.APP_SECRET;
@ -12,12 +12,12 @@ const authOptions: NextAuthOptions = {
CredentialsProvider({ CredentialsProvider({
name: 'Credentials', name: 'Credentials',
credentials: { credentials: {
username: { label: 'Username', type: 'text' }, email: { label: 'Email', type: 'text' },
password: { label: 'Password', type: 'password' }, password: { label: 'Password', type: 'password' },
}, },
authorize: async credentials => { authorize: async credentials => {
if (!credentials?.username || !credentials?.password) return null; if (!credentials?.email || !credentials?.password) return null;
const user = await getUserByUsername(credentials.username, { const user = await getUserByEmail(credentials.email, {
includePassword: true, includePassword: true,
} as any); } as any);
if (!user) return null; if (!user) return null;

View file

@ -32,6 +32,24 @@ async function findUser(
}); });
} }
export async function getUserByEmail(email: string, options: GetUserOptions = {}) {
const { includePassword = false, showDeleted = false } = options;
return prisma.client.user.findFirst({
where: {
OR: [{ email }, { username: email }],
...(showDeleted && { deletedAt: null }),
},
select: {
id: true,
username: true,
password: includePassword,
role: true,
createdAt: true,
},
}) as unknown as Promise<User>;
}
export async function getUser(userId: string, options: GetUserOptions = {}) { export async function getUser(userId: string, options: GetUserOptions = {}) {
return findUser( return findUser(
{ {
@ -222,6 +240,12 @@ export async function deleteUser(
where: { where: {
id: userId, id: userId,
}, },
select: {
id: true,
username: true,
role: true,
createdAt: true,
},
}), }),
]); ]);
} }