| Numéro du ticket | Titre du ticket | |------------------|-----------------| | #202 | Authentification — Connexion utilisateur (JWT) | ## Description de la PR [#202] Authentification — Connexion utilisateur (JWT) ## Modification du .env ## Check list - [x] Pas de régression - [ ] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #5 Reviewed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #5.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { FetchOptions } from 'ofetch'
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
@@ -26,6 +27,7 @@ export const useApi = (): ApiClient => {
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase ?? '/api'
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const nuxtApp = useNuxtApp()
|
||||
const i18n = nuxtApp.$i18n as
|
||||
| {
|
||||
@@ -70,12 +72,17 @@ export const useApi = (): ApiClient => {
|
||||
const client = $fetch.create({
|
||||
baseURL,
|
||||
retry: 0,
|
||||
onResponse({ options }) {
|
||||
credentials: 'include',
|
||||
onResponse({ options, response }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.status && response.status >= 400) {
|
||||
return
|
||||
}
|
||||
|
||||
const successKey = apiOptions?.toastSuccessKey
|
||||
const successMessage =
|
||||
apiOptions?.toastSuccessMessage ||
|
||||
|
||||
@@ -13,11 +13,20 @@
|
||||
"create": "Impossible de créer la réception.",
|
||||
"update": "Impossible de mettre à jour la réception.",
|
||||
"weigh": "Impossible de récupérer la pesée."
|
||||
},
|
||||
"auth": {
|
||||
"login": "Identifiants invalides.",
|
||||
"users": "Impossible de récupérer les utilisateurs.",
|
||||
"logout": "Impossible de se déconnecter."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"reception": {
|
||||
"update": "Réception mise à jour avec succès."
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion réussie.",
|
||||
"logout": "Déconnexion réussie."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/layouts/auth.vue
Normal file
7
frontend/layouts/auth.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-primary-500 from-primary-50 via-white to-neutral-100 text-neutral-900">
|
||||
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@
|
||||
LOGO
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<nav class="mx-8 flex gap-8 text-2xl font-bold uppercase text-white">
|
||||
<nav class="mx-8 flex flex-1 gap-8 text-2xl font-bold uppercase text-white">
|
||||
<NuxtLink to="/" custom v-slot="{ href, navigate, isExactActive }">
|
||||
<a
|
||||
:href="href"
|
||||
@@ -29,6 +29,13 @@
|
||||
</a>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto text-xl font-bold uppercase text-white transition hover:opacity-80"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mx-auto w-full max-w-[1050px] px-6 pt-[90px] pb-0">
|
||||
@@ -38,6 +45,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const isReceptionActive = computed(() => route.path.startsWith('/reception'))
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await auth.logout()
|
||||
} finally {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
17
frontend/middleware/auth.global.ts
Normal file
17
frontend/middleware/auth.global.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (to.path === '/login') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
95
frontend/pages/login.vue
Normal file
95
frontend/pages/login.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-lg">
|
||||
<span
|
||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||
>
|
||||
LOGO
|
||||
</span>
|
||||
<form
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="user-select">
|
||||
Utilisateur
|
||||
</label>
|
||||
<select
|
||||
id="user-select"
|
||||
v-model="selectedUsername"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||
:disabled="isLoadingUsers"
|
||||
>
|
||||
<option value="" disabled>Choisir un utilisateur</option>
|
||||
<option v-for="user in users" :key="user.username" :value="user.username">
|
||||
{{ user.username }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Connexion
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { getUsers } from '~/services/auth'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth'
|
||||
})
|
||||
|
||||
const users = ref<UserData[]>([])
|
||||
const isLoadingUsers = ref(true)
|
||||
const selectedUsername = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const isSubmitting = computed(() => {
|
||||
return auth.isLoading || !selectedUsername.value || !password.value
|
||||
})
|
||||
|
||||
const loadUsers = async () => {
|
||||
isLoadingUsers.value = true
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
} finally {
|
||||
isLoadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await auth.login(selectedUsername.value, password.value)
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadUsers()
|
||||
})
|
||||
</script>
|
||||
38
frontend/services/auth.ts
Normal file
38
frontend/services/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
export async function getUsers() {
|
||||
const api = useApi()
|
||||
const data = await api.get<UserData[] | { 'hydra:member': UserData[] }>('users', {}, {
|
||||
toastErrorKey: 'errors.auth.users'
|
||||
})
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
|
||||
return data['hydra:member'] ?? []
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
const api = useApi()
|
||||
return api.get<UserData>('me', {}, {
|
||||
toast: false
|
||||
})
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const api = useApi()
|
||||
return api.post<{ token: string }>('login_check', { username, password }, {
|
||||
toastErrorKey: 'errors.auth.login',
|
||||
toastSuccessKey: 'success.auth.login'
|
||||
})
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const api = useApi()
|
||||
return api.post<void>('logout', {}, {
|
||||
toastErrorKey: 'errors.auth.logout',
|
||||
toastSuccessKey: 'success.auth.logout',
|
||||
redirect: 'manual'
|
||||
})
|
||||
}
|
||||
3
frontend/services/dto/user-data.ts
Normal file
3
frontend/services/dto/user-data.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface UserData {
|
||||
username: string
|
||||
}
|
||||
58
frontend/stores/auth.ts
Normal file
58
frontend/stores/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { getCurrentUser, login, logout } from '~/services/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null as UserData | null,
|
||||
isLoading: false,
|
||||
checked: false
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => Boolean(state.user)
|
||||
},
|
||||
actions: {
|
||||
async ensureSession() {
|
||||
if (this.checked) {
|
||||
return this.user
|
||||
}
|
||||
|
||||
this.checked = true
|
||||
|
||||
try {
|
||||
const me = await getCurrentUser()
|
||||
this.user = me
|
||||
return me
|
||||
} catch {
|
||||
this.user = null
|
||||
return null
|
||||
}
|
||||
},
|
||||
async login(username: string, password: string) {
|
||||
this.isLoading = true
|
||||
|
||||
try {
|
||||
await login(username, password)
|
||||
const me = await getCurrentUser()
|
||||
this.user = me
|
||||
this.checked = true
|
||||
return me
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
this.isLoading = true
|
||||
|
||||
try {
|
||||
await logout()
|
||||
} catch {
|
||||
// Ignore logout errors so we can still clear local auth state.
|
||||
} finally {
|
||||
this.user = null
|
||||
this.checked = true
|
||||
this.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user