feat(core) : add core front layer with login and profile pages
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
<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"
|
||||
>
|
||||
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
|
||||
</span>
|
||||
<form
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<MalioInputText
|
||||
label="Nom d'utilisateur"
|
||||
autocomplete="username"
|
||||
group-class="mt-0"
|
||||
inputClass="w-full"
|
||||
v-model="username"
|
||||
/>
|
||||
|
||||
<MalioInputPassword
|
||||
v-model="password"
|
||||
label="Mot de passe"
|
||||
autocomplete="current-password"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioButton
|
||||
label="Se connecter"
|
||||
button-class="w-full"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({layout: 'auth'})
|
||||
useHead({
|
||||
title: 'Connexion'
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
const {version} = useAppVersion()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
await navigateTo('/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<NuxtLayout name="default">
|
||||
<div class="mx-auto max-w-lg px-4 py-10">
|
||||
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||
|
||||
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||
<!-- Current avatar -->
|
||||
<UserAvatar
|
||||
v-if="auth.user"
|
||||
:user="auth.user"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('profile.changeAvatar')"
|
||||
@click="avatarInput?.click()"
|
||||
/>
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
>
|
||||
|
||||
<MalioButton
|
||||
v-if="auth.user?.avatarUrl"
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="removing"
|
||||
:label="$t('profile.removeAvatar')"
|
||||
@click="onRemove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Token MCP (interne uniquement) -->
|
||||
<div
|
||||
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
|
||||
|
||||
<div v-if="auth.user?.apiToken">
|
||||
<MalioInputPassword
|
||||
:model-value="auth.user.apiToken"
|
||||
:label="$t('profile.apiToken.label')"
|
||||
readonly
|
||||
@update:model-value="() => {}"
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:content-copy"
|
||||
icon-position="left"
|
||||
:label="$t('profile.apiToken.copy')"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:refresh"
|
||||
icon-position="left"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.regenerate')"
|
||||
@click="showConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:key-plus"
|
||||
icon-position="left"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.generate')"
|
||||
@click="onRegenerate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Crop modal -->
|
||||
<AvatarCropper
|
||||
v-if="selectedFile"
|
||||
:image-file="selectedFile"
|
||||
@crop="onCrop"
|
||||
@cancel="selectedFile = null"
|
||||
/>
|
||||
|
||||
<!-- Confirm regenerate modal -->
|
||||
<Teleport v-if="showConfirm" to="body">
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $t('profile.apiToken.confirmMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('common.cancel')"
|
||||
@click="showConfirm = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.regenerate')"
|
||||
@click="onRegenerate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAvatarService } from '~/composables/useAvatarService'
|
||||
import { useApiTokenService } from '~/services/api-token'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
const { upload, remove } = useAvatarService()
|
||||
const { regenerate } = useApiTokenService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||
const removing = ref(false)
|
||||
const regenerating = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
selectedFile.value = file
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function onCrop(blob: Blob) {
|
||||
selectedFile.value = null
|
||||
if (!auth.user) return
|
||||
|
||||
try {
|
||||
await upload(auth.user.id, blob)
|
||||
await auth.refreshUser()
|
||||
} catch {
|
||||
// Upload error — $fetch will throw on non-2xx
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
if (!auth.user) return
|
||||
removing.value = true
|
||||
|
||||
try {
|
||||
await remove(auth.user.id)
|
||||
await auth.refreshUser()
|
||||
} finally {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
if (!auth.user?.apiToken) return
|
||||
if (await copyToClipboard(auth.user.apiToken)) {
|
||||
toast.success({ message: t('profile.apiToken.copied') })
|
||||
} else {
|
||||
toast.error({ message: t('profile.apiToken.copyFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate() {
|
||||
regenerating.value = true
|
||||
try {
|
||||
const newToken = await regenerate()
|
||||
if (auth.user) {
|
||||
auth.user.apiToken = newToken
|
||||
}
|
||||
showConfirm.value = false
|
||||
toast.success({ message: t('profile.apiToken.regenerated') })
|
||||
} finally {
|
||||
regenerating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user