feat: Initial authentication logic and UX

This commit is contained in:
2026-01-13 21:07:11 -05:00
parent f9958701e6
commit c1094239a3
26 changed files with 715 additions and 9 deletions

View File

@@ -0,0 +1,13 @@
export default defineAppConfig({
ui: {
colors: {
primary: "indigo",
neutral: "neutral",
},
button: {
slots: {
base: "cursor-pointer",
},
},
},
});

View File

@@ -1,2 +1,4 @@
@import "tailwindcss";
@import "@nuxt/ui";
@import "./containers.css";

View File

@@ -0,0 +1,47 @@
:root {
--ui-container: var(--breakpoint-2xl);
}
/* Container padding */
@utility px-container {
@apply px-4 sm:px-6 lg:px-8;
}
/* Container sizes */
@utility container { @apply mx-auto px-container max-w-(--breakpoint-2xl); }
@utility container-xl { @apply container max-w-(--breakpoint-xl); }
@utility container-lg { @apply container max-w-(--breakpoint-lg); }
@utility container-md { @apply container max-w-(--breakpoint-md); }
@utility container-sm { @apply container max-w-(--breakpoint-sm); }
@utility container-fluid { @apply container max-w-screen; }
@utility container-none { @apply w-full max-w-screen; }
/* Split containers */
:root {
--container-outside-margin: 0;
--container-width: 100vw;
@variant sm {
--container-outside-margin: calc(50vw - theme("screens.sm") / 2);
--container-width: theme("screens.sm");
}
@variant md {
--container-outside-margin: calc(50vw - theme("screens.md") / 2);
--container-width: theme("screens.md");
}
@variant lg {
--container-outside-margin: calc(50vw - theme("screens.lg") / 2);
--container-width: theme("screens.lg");
}
@variant xl {
--container-outside-margin: calc(50vw - theme("screens.xl") / 2);
--container-width: theme("screens.xl");
}
@variant 2xl {
--container-outside-margin: calc(50vw - theme("screens.2xl") / 2);
--container-width: theme("screens.2xl");
}
}
@utility container-left { @apply ml-(--container-outside-margin) px-container;}
@utility container-right { @apply mr-(--container-outside-margin) px-container;}
@utility container-half { width: calc(var(--container-width) / 2);}

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
const { isLoggedIn } = useAuth();
const attrs = computed(() => {
return isLoggedIn.value
? {
label: "Déconnexion",
icon: "i-lucide-log-out",
}
: {
label: "Connexion",
icon: "i-lucide-log-in",
};
});
</script>
<template>
<AuthState>
<UButton to="/connexion" v-bind="attrs" color="neutral" />
</AuthState>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
const { login } = useAuthConnexion();
const fields = [
{
name: "username",
type: "text" as const,
label: "Courriel",
placeholder: "Entrez votre courriel",
required: true,
}, {
name: "password",
label: "Mot de passe",
type: "password" as const,
placeholder: "Entrez votre mot de passe",
required: true,
},
];
</script>
<template>
<UAuthForm
:schema="authLoginFormSchema"
:fields="fields"
title="Connexion"
description="Veuillez vous identifier."
loading-auto
@submit="login"
/>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
const { logout } = useAuthConnexion();
</script>
<template>
<div class="w-full space-y-6">
<div class="flex flex-col text-center">
<div class="text-xl text-pretty font-semibold text-highlighted">
Déconnexion
</div>
<div class="mt-1 text-base text-pretty text-muted">
Veuillez confirmer la déconnexion.
</div>
</div>
<UButton
icon="i-lucide-log-out"
block
loading-auto
to="#"
label="Déconnexion"
@click="logout()"
/>
</div>
</template>

View File

@@ -0,0 +1,12 @@
<template>
<div class="w-full space-y-6">
<div class="flex flex-col text-center">
<div class="text-xl text-pretty font-semibold text-highlighted">
Redirection en cours
</div>
<div class="mt-1 text-base text-pretty text-muted">
Veuillez patienter...
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const { isLoggedIn } = useAuth();
const { isRedirecting } = useAuthConnexion();
</script>
<template>
<section data-section-name="auth-connexion" class="py-12">
<div class="container-sm">
<AuthState>
<AuthRedirecting v-if="isRedirecting" />
<template v-else>
<AuthLogoutForm v-if="isLoggedIn" />
<AuthLoginForm v-else />
</template>
</AuthState>
</div>
</section>
</template>

View File

@@ -3,5 +3,9 @@ const title = "Moonshine";
</script>
<template>
<UHeader :title="title" />
<UHeader :title="title">
<template #right>
<AuthConnexionButton />
</template>
</UHeader>
</template>

View File

@@ -0,0 +1,6 @@
export function useAuth() {
const { loggedIn: isLoggedIn, session } = useUserSession();
const hasRole = (role: string) => session.value?.user?.roles?.includes(role) || false;
const isAdmin = computed(() => hasRole("administrator"));
return { isLoggedIn, hasRole, isAdmin };
}

View File

@@ -0,0 +1,56 @@
import z from "zod";
import type { FormSubmitEvent } from "@nuxt/ui";
export const authLoginFormSchema = z.object({
username: z.email("Courriel invalide"),
password: z.string("Veuillez saisir votre mot de passe"),
});
export type AuthLoginForm = z.infer<typeof authLoginFormSchema>;
const isRedirecting = ref(false);
export function useAuthConnexion() {
const { fetch: refreshUserSession } = useUserSession();
const routeRedirect = useRoute().query.redirect as string || undefined;
// Helper: Redirect after login / logout
async function redirectTo(to: string | undefined) {
isRedirecting.value = true;
await delay(1000);
await refreshUserSession();
await navigateTo(to || routeRedirect || "/");
isRedirecting.value = false;
}
// Login
const { mutate: loginMutate } = useGraphQLMutation("AuthLogin");
async function login({ data: variables }: FormSubmitEvent<AuthLoginForm>, redirect?: string) {
try {
const { data } = await loginMutate(variables);
if (!data.login) {
throw new Error(`Échec de la connexion par mot de passe.`);
}
await redirectTo(redirect);
}
catch (error) {
console.log(error);
}
}
// Logout
async function logout(redirect?: string) {
try {
const result = await $fetch("/api/logout", { method: "POST" });
if (!result.success) {
throw new Error("Échec de la déconnexion.");
}
await redirectTo(redirect);
}
catch (error) {
console.log(error);
}
}
return { isRedirecting, login, logout };
}

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { fr } from "@nuxt/ui/locale";
import type { NuxtError } from "#app";
const props = defineProps<{ error: NuxtError }>();
const formattedError = computed(() => {
const error = {
statusCode: props.error.statusCode,
statusMessage: props.error.statusMessage,
message: props.error.message,
};
switch (error.statusCode) {
case 404:
error.statusMessage = "Page non trouvée";
break;
case 500:
error.message = "Erreur interne du serveur.";
break;
}
return error;
});
</script>
<template>
<UApp :locale="fr">
<UError :error="formattedError" />
</UApp>
</template>

View File

@@ -0,0 +1,19 @@
fragment AuthUser on User {
id
email
roles {
nodes {
name
}
}
}
mutation AuthLogin($username: String!, $password: String!) {
login( input: { provider: PASSWORD, credentials: { username: $username, password: $password }}) {
authToken
refreshToken
user {
...AuthUser
}
}
}

View File

@@ -0,0 +1,5 @@
mutation AuthRefreshToken($refreshToken: String!) {
refreshToken( input: { refreshToken: $refreshToken }) {
authToken
}
}

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
</script>
<template>
<div id="page-connexion">
<SectionAuthConnexion />
</div>
</template>