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,
"tag": "0008_skinny_warpath",
"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,
secret: env.BETTER_AUTH_SECRET || 'secret',
database: drizzleAdapter(db, { provider: 'sqlite' }),
user: {
additionalFields: {
username: {
type: 'string',
required: true,
unique: true
}
}
},
emailAndPassword: { enabled: true },
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", {
id: text("id").primaryKey(),
name: text("name").notNull(),
username: text("username").notNull().unique(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" })
.default(false)

View File

@@ -2,7 +2,10 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import type { PageServerLoad } from './$types';
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 { sql } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => {
if (event.locals.user) {
@@ -14,9 +17,28 @@ export const load: PageServerLoad = async (event) => {
export const actions: Actions = {
signInEmail: async (event) => {
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() ?? '';
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 {
await auth.api.signInEmail({
body: {
@@ -38,7 +60,33 @@ export const actions: Actions = {
const formData = await event.request.formData();
const email = formData.get('email')?.toString() ?? '';
const password = formData.get('password')?.toString() ?? '';
const confirmPassword = formData.get('confirmPassword')?.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 {
await auth.api.signUpEmail({
@@ -46,6 +94,7 @@ export const actions: Actions = {
email,
password,
name,
username,
callbackURL: '/auth/verification-success'
}
});

View File

@@ -6,6 +6,7 @@
let isSignUp = false;
let name = '';
let username = '';
let email = '';
let password = '';
let confirmPassword = '';
@@ -14,6 +15,7 @@
const handleToggle = () => {
isSignUp = !isSignUp;
name = '';
username = '';
email = '';
password = '';
confirmPassword = '';
@@ -75,18 +77,36 @@
</div>
{/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>
<label for="email" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
E-mail
<label for="identifier" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
{isSignUp ? 'E-mail' : 'E-mail ou nom d\'utilisateur'}
</label>
<input
id="email"
type="email"
name="email"
id="identifier"
type={isSignUp ? 'email' : 'text'}
name={isSignUp ? 'email' : 'identifier'}
bind:value={email}
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"
/>
</div>

View File

@@ -3,7 +3,7 @@ import type { Actions, PageServerLoad } from './$types';
import { auth } from '$lib/server/auth';
import { db } from '$lib/server/db';
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';
export const load: PageServerLoad = async (event) => {
@@ -42,7 +42,7 @@ export const load: PageServerLoad = async (event) => {
createdAt: friendship.createdAt,
requesterId: friendship.requesterId,
requesterName: user.name,
requesterEmail: user.email,
requesterUsername: user.username,
requesterImage: user.image
})
.from(friendship)
@@ -56,7 +56,7 @@ export const load: PageServerLoad = async (event) => {
createdAt: friendship.createdAt,
addresseeId: friendship.addresseeId,
addresseeName: user.name,
addresseeEmail: user.email,
addresseeUsername: user.username,
addresseeImage: user.image
})
.from(friendship)
@@ -70,7 +70,7 @@ export const load: PageServerLoad = async (event) => {
createdAt: friendship.createdAt,
friendId: friendship.addresseeId,
friendName: user.name,
friendEmail: user.email,
friendUsername: user.username,
friendImage: user.image
})
.from(friendship)
@@ -83,7 +83,7 @@ export const load: PageServerLoad = async (event) => {
createdAt: friendship.createdAt,
friendId: friendship.requesterId,
friendName: user.name,
friendEmail: user.email,
friendUsername: user.username,
friendImage: user.image
})
.from(friendship)
@@ -201,25 +201,27 @@ export const actions: Actions = {
}
const formData = await event.request.formData();
const friendEmail = formData.get('friendEmail')?.toString().trim().toLowerCase() ?? '';
const friendUsername = formData.get('friendUsername')?.toString().trim() ?? '';
if (!friendEmail) {
return fail(400, { message: 'Email requis pour envoyer une demande' });
if (!friendUsername) {
return fail(400, { message: 'Nom d\'utilisateur requis pour envoyer une demande' });
}
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' });
}
const [targetUser] = await db
.select({ id: user.id, email: user.email })
.select({ id: user.id, username: user.username })
.from(user)
.where(eq(user.email, friendEmail))
.where(sql`lower(${user.username}) = ${friendUsername.toLowerCase()}`)
.limit(1);
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

View File

@@ -12,7 +12,7 @@
let isLoading = $state(false);
let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily' | 'friends'>('profile');
let name = $state('');
let friendEmail = $state('');
let friendUsername = $state('');
let showSuccess = $state(false);
let oldPassword = $state('');
let newPassword = $state('');
@@ -222,23 +222,23 @@
isLoading = true;
return async ({ update }) => {
isLoading = false;
friendEmail = '';
friendUsername = '';
await update();
};
}}
class="mb-8 space-y-3"
>
<label for="friendEmail" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Ajouter un ami par email
<label for="friendUsername" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
Ajouter un ami par nom d'utilisateur
</label>
<div class="flex gap-2">
<input
id="friendEmail"
type="email"
name="friendEmail"
id="friendUsername"
type="text"
name="friendUsername"
required
bind:value={friendEmail}
placeholder="ami@email.com"
bind:value={friendUsername}
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"
/>
<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>
<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 class="flex gap-2">
<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>
<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>
<form method="POST" action="?/cancelFriendRequest" use:enhance>
<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>
<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>
<form method="POST" action="?/removeFriend" use:enhance>
<input type="hidden" name="friendshipId" value={friend.id} />