feat: add user admin status and profile management
- Updated user schema to include isAdmin field. - Enhanced authentication hooks to fetch and set user admin status. - Created ProfileButton component for user profile actions. - Implemented profile and password update functionality. - Added session management for user accounts. - Developed login and signup pages with form handling. - Introduced layout server for user session data. - Updated daily page to reflect character changes.
This commit is contained in:
1
drizzle/0002_large_gwen_stacy.sql
Normal file
1
drizzle/0002_large_gwen_stacy.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user` ADD `is_admin` integer DEFAULT false NOT NULL;
|
||||||
1084
drizzle/meta/0002_snapshot.json
Normal file
1084
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
|||||||
"when": 1772383366179,
|
"when": 1772383366179,
|
||||||
"tag": "0001_nostalgic_hercules",
|
"tag": "0001_nostalgic_hercules",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772390182445,
|
||||||
|
"tag": "0002_large_gwen_stacy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
9
src/app.d.ts
vendored
9
src/app.d.ts
vendored
@@ -1,18 +1,11 @@
|
|||||||
import type { User, Session } from 'better-auth/minimal';
|
import type { User, Session } from 'better-auth/minimal';
|
||||||
|
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
user?: User;
|
user?: User & { isAdmin?: boolean };
|
||||||
session?: Session;
|
session?: Session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// interface Error {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { building } from '$app/environment';
|
import { building } from '$app/environment';
|
||||||
import { auth } from '$lib/server/auth';
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { user as userTable } from '$lib/server/db/auth.schema';
|
||||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||||
|
|
||||||
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||||
@@ -9,6 +12,12 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
|||||||
if (session) {
|
if (session) {
|
||||||
event.locals.session = session.session;
|
event.locals.session = session.session;
|
||||||
event.locals.user = session.user;
|
event.locals.user = session.user;
|
||||||
|
|
||||||
|
// Fetch the isAdmin field from the database
|
||||||
|
const dbUser = await db.select({ isAdmin: userTable.isAdmin }).from(userTable).where(eq(userTable.id, session.user.id)).limit(1);
|
||||||
|
if (dbUser.length > 0) {
|
||||||
|
(event.locals.user as any).isAdmin = dbUser[0].isAdmin;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return svelteKitHandler({ event, resolve, auth, building });
|
return svelteKitHandler({ event, resolve, auth, building });
|
||||||
|
|||||||
111
src/lib/components/ProfileButton.svelte
Normal file
111
src/lib/components/ProfileButton.svelte
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { User } from 'better-auth/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: (User & { isAdmin?: boolean }) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { user }: Props = $props();
|
||||||
|
|
||||||
|
let isMenuOpen = $state(false);
|
||||||
|
let menuElement: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
isMenuOpen = !isMenuOpen;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
isMenuOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
const response = await fetch('/login?/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuElement && !menuElement.contains(event.target as Node)) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={menuElement} class="relative">
|
||||||
|
{#if user}
|
||||||
|
<button
|
||||||
|
onclick={toggleMenu}
|
||||||
|
class="flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-2 py-2 pr-4 transition hover:border-amber-300/50 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{#if user.image}
|
||||||
|
<img
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name || 'Profil'}
|
||||||
|
class="h-8 w-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
|
||||||
|
{user.name?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="max-w-[150px] truncate text-sm font-semibold text-slate-100">
|
||||||
|
{user.name || 'Utilisateur'}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 transition {isMenuOpen ? 'rotate-180' : ''}"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isMenuOpen}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-2 w-48 rounded-xl border border-white/10 bg-slate-900/95 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
onclick={closeMenu}
|
||||||
|
class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/5 hover:text-amber-100 first:rounded-t-xl"
|
||||||
|
>
|
||||||
|
Voir mon profil
|
||||||
|
</a>
|
||||||
|
{#if (user as any).isAdmin}
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
onclick={closeMenu}
|
||||||
|
class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-amber-300 transition hover:bg-white/5 hover:text-amber-200"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={handleLogout}
|
||||||
|
class="w-full border-t border-white/5 px-4 py-3 text-sm font-semibold text-red-300 transition hover:bg-red-900/20 last:rounded-b-xl"
|
||||||
|
>
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="rounded-full bg-amber-300 px-5 py-2.5 text-sm font-semibold text-slate-900 transition hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
Se connecter
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -9,6 +9,7 @@ export const user = sqliteTable("user", {
|
|||||||
.default(false)
|
.default(false)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
isAdmin: integer("is_admin", { mode: "boolean" }).default(false).notNull(),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|||||||
8
src/routes/+layout.server.ts
Normal file
8
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user || null,
|
||||||
|
session: locals.session || null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import ProfileButton from '$lib/components/ProfileButton.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children, data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
{@render children()}
|
|
||||||
|
<div class="min-h-screen bg-slate-950">
|
||||||
|
<header class="fixed top-0 right-0 left-0 z-50 border-b border-white/5 bg-slate-950/95 backdrop-blur">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||||
|
<a href="/" class="text-lg font-black uppercase tracking-[0.15em] text-amber-50 transition hover:text-amber-100">
|
||||||
|
OnePieceDle
|
||||||
|
</a>
|
||||||
|
<ProfileButton user={data.user} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="pt-20">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
}).catch(err => console.error('Failed to record win:', err));
|
}).catch(err => console.error('Failed to record win:', err));
|
||||||
|
|
||||||
// Check if it's gecko_moria for special animation
|
// Check if it's gecko_moria for special animation
|
||||||
if (dailyCharacter.id === 'gecko_moria') {
|
if (dailyCharacter.id === 'gecko_moria_gecko_moria') {
|
||||||
isGeckoMoriaWin = true;
|
isGeckoMoriaWin = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,17 +297,8 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||||
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
||||||
|
|
||||||
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-16 sm:py-20">
|
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
|
||||||
<nav class="absolute left-6 top-6 sm:left-8 sm:top-8">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="text-xl font-black uppercase tracking-[0.25em] text-amber-50 transition hover:text-amber-100"
|
|
||||||
>
|
|
||||||
OnePieceDle
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header class="flex flex-col items-start gap-6 w-full">
|
<header class="flex flex-col items-start gap-6 w-full">
|
||||||
<div class="flex w-full items-center justify-between gap-4">
|
<div class="flex w-full items-center justify-between gap-4">
|
||||||
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
|
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
|
||||||
|
|||||||
68
src/routes/login/+page.server.ts
Normal file
68
src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { APIError } from 'better-auth/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (event.locals.user) {
|
||||||
|
return redirect(302, '/');
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
signInEmail: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const email = formData.get('email')?.toString() ?? '';
|
||||||
|
const password = formData.get('password')?.toString() ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.signInEmail({
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackURL: '/auth/verification-success'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Signin failed' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Unexpected error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(302, '/');
|
||||||
|
},
|
||||||
|
signUpEmail: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const email = formData.get('email')?.toString() ?? '';
|
||||||
|
const password = formData.get('password')?.toString() ?? '';
|
||||||
|
const name = formData.get('name')?.toString() ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.signUpEmail({
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
callbackURL: '/auth/verification-success'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Registration failed' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Unexpected error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(302, '/');
|
||||||
|
},
|
||||||
|
logout: async (event) => {
|
||||||
|
await auth.api.signOut({
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(302, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
151
src/routes/login/+page.svelte
Normal file
151
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
|
||||||
|
export let form: ActionData;
|
||||||
|
|
||||||
|
let isSignUp = false;
|
||||||
|
let email = '';
|
||||||
|
let password = '';
|
||||||
|
let confirmPassword = '';
|
||||||
|
let isLoading = false;
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
isSignUp = !isSignUp;
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
form = null;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>OnePieceDle - {isSignUp ? 'Inscription' : 'Connexion'}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex min-h-screen w-full max-w-2xl flex-col items-center justify-center px-6 py-10">
|
||||||
|
<div class="w-full space-y-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-5xl">
|
||||||
|
OnePieceDle
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 text-slate-300">
|
||||||
|
{isSignUp ? 'Créer votre compte' : 'Bienvenue, pirate'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Card -->
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-8 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={isSignUp ? '?/signUpEmail' : '?/signInEmail'}
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Email Field -->
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
E-mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
placeholder="votremail@email.com"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Password Field -->
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Confirm Password Field (Sign Up Only) -->
|
||||||
|
{#if isSignUp}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="confirmPassword"
|
||||||
|
class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100"
|
||||||
|
>
|
||||||
|
Confirmer le mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
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}
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if form?.message}
|
||||||
|
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||||
|
{form.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Chargement...' : isSignUp ? 'Créer un compte' : 'Se connecter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Toggle Sign Up / Login -->
|
||||||
|
<div class="mt-6 border-t border-white/10 pt-6">
|
||||||
|
<p class="text-center text-sm text-slate-400">
|
||||||
|
{isSignUp ? 'Vous avez déjà un compte ?' : "Vous n'avez pas de compte ?"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={handleToggle}
|
||||||
|
class="text-amber-300 transition hover:text-amber-200"
|
||||||
|
>
|
||||||
|
{isSignUp ? 'Se connecter' : "S'inscrire"}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Home -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/" class="text-sm text-slate-400 transition hover:text-slate-300">
|
||||||
|
← Retour à l'accueil
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
119
src/routes/profile/+page.server.ts
Normal file
119
src/routes/profile/+page.server.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { session } from '$lib/server/db/auth.schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { APIError } from 'better-auth/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all sessions for this user
|
||||||
|
const userSessions = await db
|
||||||
|
.select()
|
||||||
|
.from(session)
|
||||||
|
.where(eq(session.userId, event.locals.user.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: event.locals.user,
|
||||||
|
sessions: userSessions
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
updateProfile: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const name = formData.get('name')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
return fail(400, { message: 'Le nom ne peut pas être vide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.updateUser({
|
||||||
|
body: {
|
||||||
|
name: name.trim()
|
||||||
|
},
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Erreur lors de la mise à jour' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Erreur inattendue' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
changePassword: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const oldPassword = formData.get('oldPassword')?.toString() ?? '';
|
||||||
|
const newPassword = formData.get('newPassword')?.toString() ?? '';
|
||||||
|
const confirmPassword = formData.get('confirmPassword')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!oldPassword.trim()) {
|
||||||
|
return fail(400, { message: 'Le mot de passe actuel est requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPassword.trim()) {
|
||||||
|
return fail(400, { message: 'Le nouveau mot de passe est requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
return fail(400, { message: 'Les mots de passe ne correspondent pas' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return fail(400, { message: 'Le mot de passe doit contenir au moins 8 caractères' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.changePassword({
|
||||||
|
body: {
|
||||||
|
currentPassword: oldPassword,
|
||||||
|
newPassword
|
||||||
|
},
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Erreur lors du changement de mot de passe' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Erreur inattendue' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
revokeSession: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const sessionId = formData.get('sessionId')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return fail(400, { message: 'ID de session manquant' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete the session from database
|
||||||
|
await db.delete(session).where(eq(session.id, sessionId));
|
||||||
|
} catch (error) {
|
||||||
|
return fail(500, { message: 'Erreur lors de la révocation de la session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Session révoquée avec succès' };
|
||||||
|
}
|
||||||
|
};
|
||||||
334
src/routes/profile/+page.svelte
Normal file
334
src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
form?: { success?: boolean; message?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let activeTab = $state<'profile' | 'password' | 'sessions'>('profile');
|
||||||
|
let name = $state('');
|
||||||
|
let showSuccess = $state(false);
|
||||||
|
let oldPassword = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let sessions = $state<any[]>([]);
|
||||||
|
let tabsElement: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
name = data.user?.name || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
sessions = (data as any).sessions || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form && form.success === true) {
|
||||||
|
showSuccess = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
showSuccess = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTabChange = (tab: 'profile' | 'password' | 'sessions') => {
|
||||||
|
activeTab = tab;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Just for type purposes
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Mon Profil - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||||
|
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex min-h-screen w-full max-w-2xl flex-col items-center px-6 py-4">
|
||||||
|
<div class="w-full space-y-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-3xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-4xl">
|
||||||
|
Mon Profil
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-300">
|
||||||
|
Modifie les informations de ton profil
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs Navigation -->
|
||||||
|
<div bind:this={tabsElement} class="sticky top-20 z-10 flex gap-2 border-b border-white/10 bg-slate-950/80 backdrop-blur">
|
||||||
|
<button
|
||||||
|
onclick={() => handleTabChange('profile')}
|
||||||
|
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'profile'
|
||||||
|
? 'border-b-2 border-amber-300 text-amber-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'}"
|
||||||
|
>
|
||||||
|
Profil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleTabChange('password')}
|
||||||
|
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'password'
|
||||||
|
? 'border-b-2 border-amber-300 text-amber-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'}"
|
||||||
|
>
|
||||||
|
Mot de passe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleTabChange('sessions')}
|
||||||
|
class="px-4 py-3 font-semibold uppercase tracking-[0.1em] transition {activeTab === 'sessions'
|
||||||
|
? 'border-b-2 border-amber-300 text-amber-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'}"
|
||||||
|
>
|
||||||
|
Sessions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
{#if activeTab === 'profile'}
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="mb-6 flex flex-col items-center gap-4">
|
||||||
|
{#if data.user.image}
|
||||||
|
<img
|
||||||
|
src={data.user.image}
|
||||||
|
alt={data.user.name || 'Profil'}
|
||||||
|
class="h-24 w-24 rounded-full border-2 border-amber-300 object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-24 w-24 items-center justify-center rounded-full border-2 border-amber-300 bg-amber-300/20 text-2xl font-semibold text-amber-100">
|
||||||
|
{data.user.name?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-slate-400">Email</p>
|
||||||
|
<p class="font-semibold text-white">{data.user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateProfile"
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
onsubmit={handleSubmit}
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Name Field -->
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
Nom d'affichage
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder="Ton nom"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if form && form.message && form.success !== true}
|
||||||
|
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||||
|
{form.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
{#if showSuccess}
|
||||||
|
<div class="rounded-lg border border-green-500/30 bg-green-900/20 px-4 py-3 text-sm text-green-200">
|
||||||
|
Profil mis à jour avec succès !
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Mise à jour...' : 'Enregistrer les modifications'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Password Tab -->
|
||||||
|
{#if activeTab === 'password'}
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
|
||||||
|
Changer le mot de passe
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/changePassword"
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
oldPassword = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Old Password Field -->
|
||||||
|
<div>
|
||||||
|
<label for="oldPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
Mot de passe actuel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="oldPassword"
|
||||||
|
type="password"
|
||||||
|
name="oldPassword"
|
||||||
|
bind:value={oldPassword}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- New Password Field -->
|
||||||
|
<div>
|
||||||
|
<label for="newPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
Nouveau mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
bind:value={newPassword}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Confirm Password Field -->
|
||||||
|
<div>
|
||||||
|
<label for="confirmPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
Confirmer le mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if form && form.message && form.success !== true}
|
||||||
|
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||||
|
{form.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
{#if showSuccess}
|
||||||
|
<div class="rounded-lg border border-green-500/30 bg-green-900/20 px-4 py-3 text-sm text-green-200">
|
||||||
|
Mot de passe changé avec succès !
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Changement en cours...' : 'Changer le mot de passe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sessions Tab -->
|
||||||
|
{#if activeTab === 'sessions'}
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
|
||||||
|
Sessions actives
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if sessions.length === 0}
|
||||||
|
<p class="text-center text-slate-400">Aucune session active</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each sessions as sess}
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold text-white">
|
||||||
|
{sess.userAgent || 'Appareil inconnu'}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-400">
|
||||||
|
IP: {sess.ipAddress || 'Inconnue'}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Créée: {new Date(sess.createdAt).toLocaleDateString('fr-FR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/revokeSession"
|
||||||
|
style="display: inline;"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="sessionId" value={sess.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg border border-red-500/50 bg-red-900/20 px-4 py-2 text-xs font-semibold text-red-300 transition hover:border-red-500 hover:bg-red-900/40"
|
||||||
|
>
|
||||||
|
Terminer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Back to Home -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/" class="text-sm text-slate-400 transition hover:text-slate-300">
|
||||||
|
← Retour à l'accueil
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
Reference in New Issue
Block a user