feat(auth): add username field to user schema and authentication process
All checks were successful
Build Docker Image / build (push) Successful in 1m12s

- Updated user schema to include a unique username field.
- Modified authentication logic to support sign-in using either email or username.
- Enhanced sign-up process to require a username and validate its uniqueness.
- Updated login and profile routes to reflect changes in user identification.
- Adjusted frontend forms to accommodate username input alongside email.
This commit is contained in:
2026-03-06 20:16:05 +01:00
parent ce08329b2d
commit 249da5ad2e
9 changed files with 1447 additions and 32 deletions

View File

@@ -0,0 +1,51 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
ALTER TABLE `user` ADD `username` text;--> statement-breakpoint
UPDATE `user`
SET `username` = `name`
WHERE `username` IS NULL OR trim(`username`) = '';--> statement-breakpoint
UPDATE `user`
SET `username` = `username` || '_' || substr(`id`, 1, 6)
WHERE `id` IN (
SELECT u1.`id`
FROM `user` u1
JOIN `user` u2
ON lower(u1.`username`) = lower(u2.`username`)
AND u1.`id` > u2.`id`
);--> statement-breakpoint
CREATE TABLE `__new_user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`username` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`is_admin` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);--> statement-breakpoint
INSERT INTO `__new_user` (
`id`,
`name`,
`username`,
`email`,
`email_verified`,
`image`,
`is_admin`,
`created_at`,
`updated_at`
)
SELECT
`id`,
`name`,
`username`,
`email`,
`email_verified`,
`image`,
`is_admin`,
`created_at`,
`updated_at`
FROM `user`;--> statement-breakpoint
DROP TABLE `user`;--> statement-breakpoint
ALTER TABLE `__new_user` RENAME TO `user`;--> statement-breakpoint
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1772821532270, "when": 1772821532270,
"tag": "0008_skinny_warpath", "tag": "0008_skinny_warpath",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1772822823122,
"tag": "0009_true_gravity",
"breakpoints": true
} }
] ]
} }

View File

@@ -9,6 +9,15 @@ export const auth = betterAuth({
baseURL: env.ORIGIN, baseURL: env.ORIGIN,
secret: env.BETTER_AUTH_SECRET || 'secret', secret: env.BETTER_AUTH_SECRET || 'secret',
database: drizzleAdapter(db, { provider: 'sqlite' }), database: drizzleAdapter(db, { provider: 'sqlite' }),
user: {
additionalFields: {
username: {
type: 'string',
required: true,
unique: true
}
}
},
emailAndPassword: { enabled: true }, emailAndPassword: { enabled: true },
plugins: [sveltekitCookies(getRequestEvent)] // make sure this is the last plugin in the array plugins: [sveltekitCookies(getRequestEvent)] // make sure this is the last plugin in the array
}); });

View File

@@ -4,6 +4,7 @@ import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", { export const user = sqliteTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
username: text("username").notNull().unique(),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }) emailVerified: integer("email_verified", { mode: "boolean" })
.default(false) .default(false)

View File

@@ -2,7 +2,10 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types'; import type { Actions } from './$types';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { auth } from '$lib/server/auth'; import { auth } from '$lib/server/auth';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { APIError } from 'better-auth/api'; import { APIError } from 'better-auth/api';
import { sql } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
if (event.locals.user) { if (event.locals.user) {
@@ -14,9 +17,28 @@ export const load: PageServerLoad = async (event) => {
export const actions: Actions = { export const actions: Actions = {
signInEmail: async (event) => { signInEmail: async (event) => {
const formData = await event.request.formData(); const formData = await event.request.formData();
const email = formData.get('email')?.toString() ?? ''; const identifier = formData.get('identifier')?.toString().trim() ?? formData.get('email')?.toString().trim() ?? '';
const password = formData.get('password')?.toString() ?? ''; const password = formData.get('password')?.toString() ?? '';
if (!identifier) {
return fail(400, { message: 'Email ou nom d\'utilisateur requis' });
}
let email = identifier;
if (!identifier.includes('@')) {
const [foundUser] = await db
.select({ email: user.email })
.from(user)
.where(sql`lower(${user.username}) = ${identifier.toLowerCase()}`)
.limit(1);
if (!foundUser) {
return fail(400, { message: 'Identifiants invalides' });
}
email = foundUser.email;
}
try { try {
await auth.api.signInEmail({ await auth.api.signInEmail({
body: { body: {
@@ -38,7 +60,33 @@ export const actions: Actions = {
const formData = await event.request.formData(); const formData = await event.request.formData();
const email = formData.get('email')?.toString() ?? ''; const email = formData.get('email')?.toString() ?? '';
const password = formData.get('password')?.toString() ?? ''; const password = formData.get('password')?.toString() ?? '';
const confirmPassword = formData.get('confirmPassword')?.toString() ?? '';
const name = formData.get('name')?.toString() ?? ''; const name = formData.get('name')?.toString() ?? '';
const username = formData.get('username')?.toString().trim() ?? '';
if (!username) {
return fail(400, { message: 'Nom d\'utilisateur requis' });
}
if (!/^[a-zA-Z0-9._-]{3,30}$/.test(username)) {
return fail(400, {
message: "Le nom d'utilisateur doit contenir 3 à 30 caractères (lettres, chiffres, ., _, -)"
});
}
const [existingUsername] = await db
.select({ id: user.id })
.from(user)
.where(sql`lower(${user.username}) = ${username.toLowerCase()}`)
.limit(1);
if (existingUsername) {
return fail(400, { message: "Ce nom d'utilisateur est déjà pris" });
}
if (password !== confirmPassword) {
return fail(400, { message: 'Les mots de passe ne correspondent pas' });
}
try { try {
await auth.api.signUpEmail({ await auth.api.signUpEmail({
@@ -46,6 +94,7 @@ export const actions: Actions = {
email, email,
password, password,
name, name,
username,
callbackURL: '/auth/verification-success' callbackURL: '/auth/verification-success'
} }
}); });

View File

@@ -6,6 +6,7 @@
let isSignUp = false; let isSignUp = false;
let name = ''; let name = '';
let username = '';
let email = ''; let email = '';
let password = ''; let password = '';
let confirmPassword = ''; let confirmPassword = '';
@@ -14,6 +15,7 @@
const handleToggle = () => { const handleToggle = () => {
isSignUp = !isSignUp; isSignUp = !isSignUp;
name = ''; name = '';
username = '';
email = ''; email = '';
password = ''; password = '';
confirmPassword = ''; confirmPassword = '';
@@ -75,18 +77,36 @@
</div> </div>
{/if} {/if}
<!-- Email Field --> <!-- Username Field (Sign Up Only) -->
{#if isSignUp}
<div>
<label for="username" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Nom d'utilisateur
</label>
<input
id="username"
type="text"
name="username"
bind:value={username}
required
placeholder="ex: luffy_gear5"
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/>
</div>
{/if}
<!-- Email / Username Field -->
<div> <div>
<label for="email" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100"> <label for="identifier" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
E-mail {isSignUp ? 'E-mail' : 'E-mail ou nom d\'utilisateur'}
</label> </label>
<input <input
id="email" id="identifier"
type="email" type={isSignUp ? 'email' : 'text'}
name="email" name={isSignUp ? 'email' : 'identifier'}
bind:value={email} bind:value={email}
required required
placeholder="votremail@email.com" placeholder={isSignUp ? 'votremail@email.com' : 'votremail@email.com ou luffy_gear5'}
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30" class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/> />
</div> </div>

View File

@@ -3,7 +3,7 @@ import type { Actions, PageServerLoad } from './$types';
import { auth } from '$lib/server/auth'; import { auth } from '$lib/server/auth';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { session, userCharacterHistory, characterHistory, character, friendship, user } from '$lib/server/db/schema'; import { session, userCharacterHistory, characterHistory, character, friendship, user } from '$lib/server/db/schema';
import { and, desc, eq, or } from 'drizzle-orm'; import { and, desc, eq, or, sql } from 'drizzle-orm';
import { APIError } from 'better-auth/api'; import { APIError } from 'better-auth/api';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
@@ -42,7 +42,7 @@ export const load: PageServerLoad = async (event) => {
createdAt: friendship.createdAt, createdAt: friendship.createdAt,
requesterId: friendship.requesterId, requesterId: friendship.requesterId,
requesterName: user.name, requesterName: user.name,
requesterEmail: user.email, requesterUsername: user.username,
requesterImage: user.image requesterImage: user.image
}) })
.from(friendship) .from(friendship)
@@ -56,7 +56,7 @@ export const load: PageServerLoad = async (event) => {
createdAt: friendship.createdAt, createdAt: friendship.createdAt,
addresseeId: friendship.addresseeId, addresseeId: friendship.addresseeId,
addresseeName: user.name, addresseeName: user.name,
addresseeEmail: user.email, addresseeUsername: user.username,
addresseeImage: user.image addresseeImage: user.image
}) })
.from(friendship) .from(friendship)
@@ -70,7 +70,7 @@ export const load: PageServerLoad = async (event) => {
createdAt: friendship.createdAt, createdAt: friendship.createdAt,
friendId: friendship.addresseeId, friendId: friendship.addresseeId,
friendName: user.name, friendName: user.name,
friendEmail: user.email, friendUsername: user.username,
friendImage: user.image friendImage: user.image
}) })
.from(friendship) .from(friendship)
@@ -83,7 +83,7 @@ export const load: PageServerLoad = async (event) => {
createdAt: friendship.createdAt, createdAt: friendship.createdAt,
friendId: friendship.requesterId, friendId: friendship.requesterId,
friendName: user.name, friendName: user.name,
friendEmail: user.email, friendUsername: user.username,
friendImage: user.image friendImage: user.image
}) })
.from(friendship) .from(friendship)
@@ -201,25 +201,27 @@ export const actions: Actions = {
} }
const formData = await event.request.formData(); const formData = await event.request.formData();
const friendEmail = formData.get('friendEmail')?.toString().trim().toLowerCase() ?? ''; const friendUsername = formData.get('friendUsername')?.toString().trim() ?? '';
if (!friendEmail) { if (!friendUsername) {
return fail(400, { message: 'Email requis pour envoyer une demande' }); return fail(400, { message: 'Nom d\'utilisateur requis pour envoyer une demande' });
} }
const me = event.locals.user; const me = event.locals.user;
if (friendEmail === me.email?.toLowerCase()) { const myUsername = (me as { username?: string }).username;
if (myUsername && friendUsername.toLowerCase() === myUsername.toLowerCase()) {
return fail(400, { message: 'Tu ne peux pas t\'ajouter toi-même' }); return fail(400, { message: 'Tu ne peux pas t\'ajouter toi-même' });
} }
const [targetUser] = await db const [targetUser] = await db
.select({ id: user.id, email: user.email }) .select({ id: user.id, username: user.username })
.from(user) .from(user)
.where(eq(user.email, friendEmail)) .where(sql`lower(${user.username}) = ${friendUsername.toLowerCase()}`)
.limit(1); .limit(1);
if (!targetUser) { if (!targetUser) {
return fail(404, { message: 'Aucun utilisateur trouvé avec cet email' }); return fail(404, { message: 'Aucun utilisateur trouvé avec ce nom d\'utilisateur' });
} }
const [existing] = await db const [existing] = await db

View File

@@ -12,7 +12,7 @@
let isLoading = $state(false); let isLoading = $state(false);
let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily' | 'friends'>('profile'); let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily' | 'friends'>('profile');
let name = $state(''); let name = $state('');
let friendEmail = $state(''); let friendUsername = $state('');
let showSuccess = $state(false); let showSuccess = $state(false);
let oldPassword = $state(''); let oldPassword = $state('');
let newPassword = $state(''); let newPassword = $state('');
@@ -222,23 +222,23 @@
isLoading = true; isLoading = true;
return async ({ update }) => { return async ({ update }) => {
isLoading = false; isLoading = false;
friendEmail = ''; friendUsername = '';
await update(); await update();
}; };
}} }}
class="mb-8 space-y-3" class="mb-8 space-y-3"
> >
<label for="friendEmail" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100"> <label for="friendUsername" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Ajouter un ami par email Ajouter un ami par nom d'utilisateur
</label> </label>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
id="friendEmail" id="friendUsername"
type="email" type="text"
name="friendEmail" name="friendUsername"
required required
bind:value={friendEmail} bind:value={friendUsername}
placeholder="ami@email.com" placeholder="ex: luffy_gear5"
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30" class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
/> />
<button <button
@@ -265,7 +265,7 @@
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3"> <div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div> <div>
<p class="font-semibold text-white">{req.requesterName}</p> <p class="font-semibold text-white">{req.requesterName}</p>
<p class="text-xs text-slate-400">{req.requesterEmail}</p> <p class="text-xs text-slate-400">@{req.requesterUsername}</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<form method="POST" action="?/acceptFriendRequest" use:enhance> <form method="POST" action="?/acceptFriendRequest" use:enhance>
@@ -293,7 +293,7 @@
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3"> <div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div> <div>
<p class="font-semibold text-white">{req.addresseeName}</p> <p class="font-semibold text-white">{req.addresseeName}</p>
<p class="text-xs text-slate-400">{req.addresseeEmail}</p> <p class="text-xs text-slate-400">@{req.addresseeUsername}</p>
</div> </div>
<form method="POST" action="?/cancelFriendRequest" use:enhance> <form method="POST" action="?/cancelFriendRequest" use:enhance>
<input type="hidden" name="friendshipId" value={req.id} /> <input type="hidden" name="friendshipId" value={req.id} />
@@ -315,7 +315,7 @@
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3"> <div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div> <div>
<p class="font-semibold text-white">{friend.friendName}</p> <p class="font-semibold text-white">{friend.friendName}</p>
<p class="text-xs text-slate-400">{friend.friendEmail}</p> <p class="text-xs text-slate-400">@{friend.friendUsername}</p>
</div> </div>
<form method="POST" action="?/removeFriend" use:enhance> <form method="POST" action="?/removeFriend" use:enhance>
<input type="hidden" name="friendshipId" value={friend.id} /> <input type="hidden" name="friendshipId" value={friend.id} />